Routing
Fresh's file-system router maps routes/ to URLs. It is activated by the final
line of main.ts:
app.fsRoutes();
Everything registered with app.use(…) before that line runs first, in order.
Middleware chain
| Order | Middleware | Source | Responsibility |
|---|---|---|---|
| 1 | staticFiles() |
main.ts |
Serves static/ at the root — favicons, assets |
| 2 | state defaults | main.ts |
Sets locale / dir on State |
| 3 | serveLogos |
lib/assets.ts |
/logos/* from assets/logos/, guarded + cached |
| 4 | serveMikro |
lib/mikro/serve.ts |
/m/<app>/* from a hosted app's dist/ |
| 4 | app.fsRoutes() |
main.ts |
The routes/ tree |
Because staticFiles() runs first, a file in static/ shadows any route of the
same path.
serveLogos
Serves /logos/* from assets/logos/. Things to know:
- The path is guarded before any disk access. Anything containing
.., a leading/, a backslash or a NUL is rejected and falls through. - Content types come from a map covering
png,svg,jpg,jpeg,webp,gif,avifandico; anything else isapplication/octet-stream. - Responses carry
Cache-Control: public, max-age=3600, must-revalidateand anETagderived from size and mtime, and a matchingIf-None-Matchgets a304. - A missing file falls through to
ctx.next(), so it ends up as a normal 404 rather than an exception.
Why this exists at all: assets/ is Vite's source directory (it holds
styles.css, which is bundled), so its images are not automatically published
the way static/ is. This middleware exposes them without moving them.
Route table
| URL | File | Kind | Response |
|---|---|---|---|
/ |
routes/index.tsx |
page | Landing page — live preview of every demo |
/o/<uuid> |
routes/o/[uuid].tsx |
page | The link page — dispatches on link.type |
/o/<uuid>/vcard |
routes/o/[uuid]/vcard.ts |
handler | text/vcard attachment |
/builder |
routes/builder/index.tsx |
handler | 307 to the first type — BUILDER.md |
/builder/<type> |
routes/builder/[type].tsx |
page | The builder for one link type |
/builder/preview |
routes/builder/preview.tsx |
page | POST only: renders a draft record |
/builder/schema |
routes/builder/schema.ts |
handler | The node vocabulary as JSON |
/logos/<file> |
middleware | asset | Image from assets/logos/ |
/favicon.svg |
static/favicon.svg |
asset | The chain-link mark; what modern browsers |
/favicon.ico |
static/favicon.ico |
asset | Same artwork, 16/32/64 raster frames |
/apple-touch-icon.png |
static/apple-touch-icon.png |
asset | 180×180, full-bleed (iOS masks it itself) |
/alpha/<path> |
static/alpha/<path> |
asset | Per-document Alpha assets |
| (any unmatched) | routes/_404.tsx |
page | 404 via ErrorDisplay |
| (uncaught throw) | routes/_500.tsx |
page | 500 via ErrorDisplay |
Special files, applied by Fresh convention rather than by path:
| File | Role |
|---|---|
routes/_app.tsx |
Wraps every page — <html>, <head>, <body>. Nothing else |
routes/_404.tsx |
Not-found page |
routes/_500.tsx |
Server-error page |
/docs — the documentation
docs/*.md is discovered, not registered: listDocs
(lib/docs.ts) reads the directory at request time, so adding a
file publishes it with no code change. The slug is the lower-cased filename
without its extension, and it is validated against ^[a-z0-9][a-z0-9-]*$
before any filesystem call — the same discipline as isValidUuid and
isSafeAssetPath, because the slug is interpolated into a path.
Markdown is rendered by marked and passed through the same allowlist sanitiser
the Alpha rawHtml node uses. Two link rewrites happen on the way:
| In the file | On /docs |
|---|---|
[Routing](ROUTING.md#foo) |
/docs/routing#foo |
[lib/data.ts](../lib/data.ts) |
lib/data.ts as plain <code> |
Source links are unwrapped rather than pointed at GitHub because the repository
is private, so the link would 404 for a reader. Heading anchors reproduce
GitHub's algorithm exactly, so a #fragment written for GitHub still lands here
— tests/docs_test.ts asserts every one of them resolves.
Rendered documents are cached by file mtime, like links.
_app.tsx — the HTML shell
export default define.page(function App({ Component }) {
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>onnne.link</title>
</head>
<body>
<Component />
</body>
</html>
);
});
Notes:
<title>onnne.link</title>is the default; individual pages override it with Fresh's<Head>component fromfresh/runtime.- No third-party script here. The unpkg
lottie-playerloader used to sit in this shell and therefore ran on every page for the sake of the error page's animation. It now loads fromcomponents/ErrorDisplay.tsx, its only consumer, via<Head>. A link page now makes no outbound request at all — which matters most on the home page, whose five demo iframes would otherwise each have pulled it again. - There is no
<html lang>attribute. Worth adding, especially given the Arabic-language content in scope. - The stylesheet is not linked here — Vite injects it via the Fresh plugin,
sourced from
client.ts.
/o/[uuid] — the product route
routes/o/[uuid].tsx is split into a handler
and a page. The handler exists so the response can carry a real status code:
a page component on its own always renders 200, which is why a deleted link
used to answer "404 Not Found" in the body with 200 OK on the wire.
export const handler = define.handlers<Data>({
async GET(ctx) {
const result = await getLinkByUuid(ctx.params.uuid);
if (result.status === "not-found") {
return page({ kind: "not-found" }, { status: 404 });
}
if (result.status === "invalid") {
return page({ kind: "invalid" }, { status: 500 });
}
// ...set locale/dir, build the QR for business cards, then:
return page({ kind: "link", link, qrCodeSvg });
},
});
export default define.page<typeof handler>(
function LinkPage({ data }) {/* ... */},
);
Alpha records take an extra step: the node tree is re-validated through the
registry before rendering, so a document that names a node type the current
build no longer has produces the same 500 rather than a broken page.
Three outcomes, three status codes:
| Condition | Status | Page |
|---|---|---|
| Link found and valid | 200 |
the type's component |
| No such link | 404 |
"Link Not Found" |
| Record exists but fails validation | 500 |
"Link Unavailable" |
The 500 is deliberate. A malformed record is a server-side fault that someone
has to fix, not a missing page, and flattening it into a 404 would hide a real
problem behind a normal-looking response. The validation errors are logged
server-side; the visitor is told the link is misconfigured, not why.
The handler also sets ctx.state.locale and ctx.state.dir from
config.locale, which is what _app.tsx renders onto <html>.
Per-type metadata. Each branch sets its own <title>,
meta[name=description] and a <SocialMeta> block emitting og:* and
twitter:* tags. og:image is the link's logo, made absolute with
siteOrigin() because chat clients fetch it with no page context to resolve a
relative path against.
/o/[uuid]/vcard — the download handler
routes/o/[uuid]/vcard.ts is a .ts file
exporting a handler rather than a page:
export const handler = define.handlers({
async GET(ctx) {
const result = await getLinkByUuid(ctx.params.uuid);
if (result.status === "invalid") {
return new Response("Link Unavailable", { status: 500 });
}
if (result.status !== "ok" || result.link.type !== "business-card") {
return new Response("Not Found", { status: 404 });
}
const { data } = result.link;
return new Response(generateVCard(data), {
headers: {
"Content-Type": "text/vcard",
"Content-Disposition":
`attachment; filename="${data.firstName}_${data.lastName}.vcf"`,
},
});
},
});
- Only
GETis defined; other methods get Fresh's default handling. - The type guard is load-bearing: requesting the vCard of a
links-pagereturns 404, so the URL cannot be used to probe which links exist. - The link is loaded a second time here — the page render and the download are independent requests — but the link cache means this is not a second disk read.
- The filename is built by interpolation without sanitising; names containing
"or/would produce a malformed header.
The builder routes
The link type is a path segment, /builder/<type>, which is what lets the
type tabs be ordinary links: they work before hydration and cannot be broken by
anything client-side. /builder itself has no content and 307s to the first
type, and an unrecognised type is a 404 rather than a silent fallback — a stale
/builder/web-page bookmark should say so.
This puts a dynamic segment beside two static ones. That is safe because Fresh's
getRoutePathScore ranks a literal segment (2) above [dynamic] (1) and
index (3) above both, so schema.ts and preview.tsx keep their own paths.
GET /builder/schema serves the node registry projected to JSON. It exists so
the builder can generate its property panels without importing the renderer —
see BUILDER.md for why that seam is worth an endpoint.
/builder/preview — the draft renderer
routes/builder/preview.tsx answers POST only,
and answers the same request two ways from one validation:
Accept |
Body |
|---|---|
application/json |
{ ok, errors[] } — drives the builder's error bar |
| anything else | the rendered page — fills the builder's <iframe> |
The draft arrives as a form field, is validated by parseLink, and is rendered
through LinkPageView — the same component /o/<uuid> uses, which is what
keeps a preview from drifting from the page it claims to show.
A second form field, assets, maps /u/<id> references to blob: URLs for
images the builder holds in the browser. They are substituted after
validation, so parseLink sees the record exactly as it would be published, and
only blob: values under /u/ keys are accepted — otherwise a POST body
could put javascript: into an attribute rendered on our own origin.
Nothing is stored: there is no session and no draft state on the server. GET
is 405, because a bookmarkable preview URL would imply a persistence that does
not exist. Every response carries X-Robots-Tag: noindex, nofollow and
Cache-Control: no-store, and a mikro draft is refused — that record names a
directory on disk, so serving one would let a POST body choose which application
the origin returns.
Full design: BUILDER.md.
Nested route directory
Note the layout that makes both /o/<uuid> and /o/<uuid>/vcard work:
routes/o/
├── [uuid].tsx → /o/:uuid
└── [uuid]/
└── vcard.ts → /o/:uuid/vcard
A file and a directory with the same [uuid] name coexist. Adding more
sub-resources (/o/:uuid/qr.png, say) means adding files to routes/o/[uuid]/.
Import alias
deno.json maps "@/" to ./, so routes import with absolute-from-root paths:
import { define } from "@/utils.ts";
import { getLinkByUuid } from "@/lib/data.ts";
Usage is inconsistent — routes/o/ uses @/, while routes/index.tsx,
_app.tsx, _404.tsx and _500.tsx use relative ../ imports, and
components/ uses @/ for lib/. Prefer @/ in new code.
The define helper
Every route is wrapped in define.page(…) or define.handlers(…), from
utils.ts:
export interface State {}
export const define = createDefine<State>();
State carries locale and dir, defaulted by middleware in main.ts and
overridden per link by the /o/[uuid] handler. routes/_app.tsx reads them to
render <html lang dir>. Add fields here and they become typed in every route
and middleware automatically.
Test coverage
tests/app_test.ts mounts these route modules and the
/logos/ middleware on a bare App and asserts the HTTP contract: status codes
for every outcome, content types, Content-Disposition, cache headers, 304
handling and the path-traversal guard. It registers routes explicitly rather
than calling fsRoutes(), so the suite needs no build.
Related
- Where the routes send their data → DATA-MODEL.md
- What each branch renders → LINK-TYPES.md
- Authoring a record through a form → BUILDER.md
- Missing status codes, OG tags, caching → ROADMAP.md