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, avif and ico; anything else is application/octet-stream.
  • Responses carry Cache-Control: public, max-age=3600, must-revalidate and an ETag derived from size and mtime, and a matching If-None-Match gets a 304.
  • 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 from fresh/runtime.
  • No third-party script here. The unpkg lottie-player loader used to sit in this shell and therefore ran on every page for the sake of the error page's animation. It now loads from components/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 GET is defined; other methods get Fresh's default handling.
  • The type guard is load-bearing: requesting the vCard of a links-page returns 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.