Development and deployment
Prerequisites
- Deno 2.9.6 — the version CI pins;
deno fmtoutput differs between Deno releases, so a mismatch shows up as a formatting failure in CI — https://docs.deno.com/runtime/getting_started/installation
That is the entire toolchain for onnne.link itself. deno.json declares every
dependency and deno.lock pins it; nodeModulesDir is set to "manual", so
npm packages are materialised into node_modules/ by Deno when needed.
The apps under mikro/ are the exception — each brings its own toolchain, and
building them needs Node.js and pnpm. You only need those if you are
rebuilding a hosted app; see MIKRO.md.
Tasks
Defined in deno.json:
| Task | Command | Purpose |
|---|---|---|
deno task dev |
vite |
Dev server with HMR, default port 5173 |
deno task build |
vite build |
Production build into _fresh/ |
deno task start |
deno serve -A _fresh/server.js |
Serve the build |
deno task test |
deno test -A |
Run the test suite |
deno task check |
deno fmt --check . && deno lint . && deno check |
Format, lint, and typecheck — run before every commit |
deno task update |
deno run -A -r jsr:@fresh/update . |
Upgrade the Fresh framework in place |
Plus one task per hosted app, and a fan-out that runs them all:
| Task | Purpose |
|---|---|
deno task mikro:build |
Build every app under mikro/ |
deno task mikro:build:<app> |
Build one app, with its own --base=/m/… |
These shell out to pnpm inside the app directory, so they need Node.js. Deno
Deploy runs mikro:build before build via the deploy block in deno.json.
Development
deno task dev
Vite serves on http://localhost:5173 with hot module reloading. CSS changes
hot-reload because client.ts imports the stylesheet.
Adding or editing a file in data/links/ does not trigger HMR — the JSON is
read at request time by Deno.readFile, outside Vite's module graph. Just
reload the page; the change is picked up because nothing is cached.
Checks
deno task check
Runs three things in sequence, stopping at the first failure:
deno fmt --check .— formatting. Covers.ts,.tsx,.jsonand.md, so the files indocs/are checked too. Fix withdeno fmt ..deno lint .— with thefreshandrecommendedrule tags enabled.deno check— TypeScript across the project.
_fresh/ is excluded from all three via the top-level exclude in deno.json.
deno task test
Eight layers, none of which need a build or a free port:
- Registry (
tests/demos_test.ts) — asserts every link type has a demo, that each resolves and is served, that the home page links to it and previews it in an inert iframe, that the Alpha demo'srawHtmlblock survives sanitisation, and that no demo carries a real contact detail. - Docs (
tests/docs_test.ts) — that every file indocs/is reachable at/docswithout being registered anywhere, that a slug cannot escape the directory, that every cross-doc link and#fragmentresolves, and — since these files are now public — that none of them publishes a customer identifier. - Island dependencies (
tests/islands_test.ts) — runsdeno infoover each island's runtime module graph and fails if it reaches an npm package outside a browser-safe allowlist. This exists because it was violated:sanitize-htmldraggednode:fs/node:path/node:urlinto both island chunks and no island hydrated at all, while every other test stayed green. See ALPHA.md. - Unit (
tests/{validate,data,vcard,phone,icons}_test.ts) — validation, vCard escaping, phone normalisation, icon fallbacks, the link cache, plus a fixture test asserting every file indata/links/validates and that eachuuidfield matches its filename. - Rendering snapshots (
tests/render_test.ts) — every page type rendered to HTML and compared againsttests/__snapshots__/render_test.ts.snap. Fixtures are synthetic and cover branches no live link reaches, so adding a real link does not churn them. - HTTP (
tests/app_test.ts) — the real route modules and middleware mounted on a bareApp, asserting status codes, content types, cache headers, the path-traversal guard, and the builder preview route's two representations. - Builder (
tests/builder_test.ts) — the path helpers behind the draft state (including that they never mutate their input), uuid generation, that every starter template passesparseLink, that the vocabulary served by/builder/schemamatches the node registry field for field, and that a freshly inserted node of every type validates. - Boundaries (
tests/builder_boundary_test.ts,tests/islands_test.ts) — what may import what. The builder must reach the app only over HTTP, and no island may reach a Node-only dependency. Both walk the runtime module graph withdeno info, shared throughtests/module_graph.ts. The islands one exists because it was violated: anode:fsimport reached both island bundles and neither hydrated, while every other test stayed green.
One check does need the build, so it runs in CI after deno task build rather
than in the suite: no file in _fresh/client/assets/ may import a node:
specifier. tests/islands_test.ts guards the module graph; that step guards the
output Vite actually emits.
A changed snapshot means the markup changed. Review the diff, and only then regenerate:
deno task test:update
Production build
deno task build
deno task start
vite build emits a self-contained server bundle plus hashed client assets into
_fresh/. deno serve -A _fresh/server.js then serves it.
Runtime requirements
Working directory matters. Both getLinkByUuid and the asset middleware
build relative paths:
`./data/links/${uuid}.json` // lib/data.ts:86
`./assets/logos/${…}` // lib/assets.ts
The process must therefore be started from the repository root. Running
deno serve from anywhere else yields a site where every link 404s and no logo
loads.
These directories must ship with the build:
| Directory | Why |
|---|---|
data/links/ |
Read at request time — not bundled into _fresh/ |
docs/ |
Rendered at request time by /docs — not bundled |
assets/logos/ |
Read at request time by the middleware — not bundled |
static/ |
Served by staticFiles() |
mikro/*/dist/ |
Read at request time by serveMikro — not bundled |
_fresh/ |
The build output itself |
This is the single most common deployment mistake: shipping only _fresh/
produces a server that starts cleanly and 404s on every link.
-A (allow-all) is required, or at minimum:
| Permission | Needed for |
|---|---|
--allow-read=. |
Deno.readTextFile / Deno.readFile |
--allow-net |
Binding the HTTP listener |
--allow-env |
Deno/Fresh runtime configuration |
Tightening -A to an explicit set is a reasonable hardening step; nothing in
the app needs write access.
Port. deno serve defaults to 8000 and honours --port. Set it explicitly
behind a reverse proxy.
Environment variables.
| Variable | Default | Effect |
|---|---|---|
SITE_ORIGIN |
https://onnne.link |
Origin used for QR content, og:url and og:image |
SITE_ORIGIN is the only one. It defaults to production, so an unconfigured
deployment renders exactly what production renders. Set it on staging or locally
if you need QR codes that point back at that deployment:
SITE_ORIGIN=http://localhost:8000 deno task start
Outbound dependency at runtime: every page loads
https://unpkg.com/@lottiefiles/lottie-player@2.0.4/dist/lottie-player.js from
the client. If unpkg is unreachable the pages still work — only the error-page
animation is lost.
Example: minimal container
FROM denoland/deno:2.9.6
WORKDIR /app
COPY . .
RUN deno task build
EXPOSE 8000
CMD ["deno", "serve", "-A", "--port", "8000", "--host", "0.0.0.0", "_fresh/server.js"]
COPY . . before the build is deliberate — it keeps data/links/ and
assets/logos/ in the image, and WORKDIR /app guarantees the relative paths
resolve. Add a .dockerignore for node_modules/, _fresh/ and .git/.
Publishing a content change
Because links live in git, publishing is a deploy:
deno fmt .
deno task check
git add data/links/ assets/logos/
git commit -m "feat(links): add …"
git push
# then rebuild + restart the server
See OPERATIONS.md for the authoring steps, or
BUILDER.md to assemble the record at /builder and export it.
Ignored paths
From .gitignore:
.env, .env.*.local # not currently used by any code
_fresh/ # build output
node_modules/ # npm deps materialised by Deno
vendor/ # vendored deps
.claude/.docs-dirty # docs-staleness sentinel (see CLAUDE.md)
Never edit _fresh/ — it is regenerated by every build.
Editor setup
.vscode/extensions.json recommends:
denoland.vscode-deno— LSP, formatting, lintbradlc.vscode-tailwindcss— class autocomplete
.vscode/settings.json enables deno.enable and
deno.lint, sets the Deno extension as the default formatter for TS/TSX/JS/JSX,
and maps *.css to the tailwindcss language mode so IntelliSense understands
@import "tailwindcss".
If TypeScript errors appear across the project in VS Code, the built-in TS
server is probably competing with the Deno LSP — confirm deno.enable: true is
active for the workspace and reload the window.
CI
.github/workflows/ci.yml runs deno task check,
deno task test and deno task build on every push to main and every pull
request.
deno task check runs deno fmt --check, deno lint and deno check in an
&& chain, so a lint failure short-circuits before the typecheck. If you are
debugging, run the three separately.
The suite includes a fixture test asserting that every file in data/links/
validates and that each uuid field matches its filename — that is what stops a
malformed link reaching production.
Related
- Runtime architecture → ARCHITECTURE.md
- Content workflow → OPERATIONS.md
- Build-affecting config → STYLING.md