Devora.js 0.3.4: catch-all routes and a faster static path

Catch-all routes for pages and API routes, pre-rendered pages served from memory with ETags, correct content types, sitemap URLs from getStaticParams(), and the version number that skipped 0.3.3.

0.3.4 is the release after 0.3.2. There is no 0.3.3 on npm: it was prepared and never published, and its changes are part of 0.3.4 (the CHANGELOG says so). @devorajs/core and @devorajs/cli are 0.3.4, @devorajs/adapter-netlify is 0.2.4, and create-devora stayed at 0.3.2 but now pins ^0.3.4.

Catch-all routes

A file named [...slug].tsx matches one or more segments; [[...slug]].tsx also matches none. Both work for pages and for API routes. ctx.params.slug is the segments joined with / and is still a string, so code written for [id] routes compiles unchanged; ctx.catchAll.slug (or req.catchAll.slug in an API route) is the string[].

// apps/marketing/routes/docs/[...slug].tsx
import type { RequestContext } from "@devorajs/core";
import { PageShell } from "@devorajs/core";

export const renderMode = "ssg";

export function getStaticParams() {
  return [{ slug: ["intro"] }, { slug: ["guide", "setup"] }];
}

export function loader(ctx: RequestContext) {
  return { path: ctx.params.slug, segments: ctx.catchAll.slug };
}

export default function Docs({ data }: { data?: { path: string; segments: string[] } }) {
  return (
    <PageShell appName="marketing">
      <h1>/docs/{data?.path}</h1>
      <p>{data?.segments.length} segment(s): {data?.segments.join(" > ")}</p>
    </PageShell>
  );
}
// apps/marketing/endpoints/files/[...path].ts
import { apiRoute } from "@devorajs/core";

export const methods = ["GET"];
export const handler = apiRoute((req) => ({
  status: 200,
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ path: req.params.path, segments: req.catchAll.path }),
}));

Both files were built and served with devora build and devora start on the published 0.3.5 packages (catch-all routes have been there since 0.3.4; this was not re-run on 0.3.4 itself), with sitemap: true in the app's app.config.ts. The build printed pre-rendered (ssg/isr): /docs/intro, /docs/guide/setup. Requests returned:

GET /docs/intro          200
GET /docs/guide/setup    200   <h1>/docs/guide/setup</h1>  2 segment(s): guide > setup
GET /docs                404   (a catch-all needs at least one segment; [[...slug]] would match)
GET /docs/other/page     404   (not in getStaticParams(), so never rendered)
GET /api/files/a/b/c     200   {"path":"a/b/c","segments":["a","b","c"]}
GET /api/files           404   (JSON)
sitemap.xml lists        /docs/guide/setup and /docs/intro

The rules, from the pages guide: a static file wins over [param], which wins over [...slug]; the catch-all must be the last segment; each segment is URL-decoded once, and a segment that is empty, ., .., or contains /, \ or NUL (including as %2F, %5C, %00) never matches. A dot in a URL is no longer taken for a file: /docs/v1.2/intro reaches the catch-all unless a real file exists at that path.

A faster static path

devora start and the adapters now keep pre-rendered pages and built files in memory (64 MB in total, 2 MB per file; larger files are streamed from disk), check them against the disk at most once a second so a rebuild shows up without a restart, and send strong ETags (a repeat visit gets a 304), HEAD, Content-Length, and brotli or gzip for text types. The route table and the module imports are memoized instead of being worked out on every request. Content types are correct for .xml, .webmanifest, fonts, .avif, .pdf, .wasm and more.

On the benchmark's tiny pre-rendered page, on one machine, that took the page from about 14,700 requests per second on 0.3.2 to between 71,000 and 82,000 in two full runs on 0.3.4. Astro, SvelteKit and React Router measured about 36,000 to 38,000 on the same page. The page is a few hundred bytes and the load tool runs on the same machine, so this mostly measures the HTTP layer.

What an existing app has to check

The CHANGELOG lists these as behavior changes, with what to do:

  • A file named [...name].tsx used to be a single [param] whose name started with dots; it is now a catch-all. Rename it if you did not mean that.
  • getStaticParams() values are percent-encoded in the output path. Nothing to do unless you read dist/static by hand.
  • sitemap.xml lists the URLs of dynamic routes that export getStaticParams().
  • devora start answers any method other than GET and HEAD to a public file, a built asset or a pre-rendered page with 405 and Allow: GET, HEAD. A form that posted to a pre-rendered page never ran an action there; post to an ssr route.
  • With sitemap off, a hand-written public/sitemap.xml is now served; it used to 404 in production.

The full table is on Upgrading.