GitHunt
BC

bcomnes/domstack-fastify

Extremely WIP

@domstack/fastify

latest version
Actions Status
downloads
Types in JS
neostandard javascript style
Socket Badge

A Fastify plugin that brings the domstack page/layout/variable-cascade system into a Fastify server. Pages have access to the full request context alongside the domstack variable cascade — enabling dynamic, server-rendered hypermedia apps using the same src directory structure you already know.

npm install @domstack/fastify

Usage

import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import domstackFastify from '@domstack/fastify'

const fastify = Fastify()

// Register @fastify/static first, pointing at your dest directory.
// @domstack/fastify does not register it for you.
await fastify.register(fastifyStatic, {
  root: new URL('public', import.meta.url).pathname,
  prefix: '/',
  index: false,
  decorateReply: false,
})

await fastify.register(domstackFastify, {
  src: 'src',    // domstack source directory
  dest: 'public' // output directory for client bundles and static assets
})

await fastify.listen({ port: 3000 })

On startup the plugin:

  1. Discovers pages, layouts, and global files in src
  2. Runs esbuild to bundle client JS/CSS into dest
  3. Copies static assets from src to dest
  4. Pre-renders page.js, page.md, and page.html files into dest/ as static files
  5. Spawns a render worker (isolated ESM module cache) for page rendering
  6. Registers a GET route for each page.route.js file and loose markdown page

Options

Option Type Default Description
src string Path to the domstack source directory (required)
dest string Output directory for client bundles and static assets (required)
watch boolean false Enable watch mode: rebuild on file changes, respawn render worker for ESM cache busting
prefix string '' URL prefix for all page routes (e.g. '/app'). Static assets always serve from /.
ignore string[] [] Glob patterns to exclude during page discovery
buildDrafts boolean false Include draft pages (.draft.{js,ts,md,html})
target string[] esbuild default esbuild target environments (e.g. ['es2020'])

Pages

@domstack/fastify uses two distinct page filename conventions to separate static pre-rendering from dynamic server-rendered routes:

Filename Behavior
page.js / page.ts Static — pre-rendered at startup, written to dest/, served as a static file
page.route.js / page.route.ts Dynamic — registered as a Fastify GET route, rendered per-request with request/reply context
page.md, page.html, README.md Static — always pre-rendered as static files

Dynamic pages (page.route.js)

page.route.js files become Fastify GET routes. The default export receives the standard domstack params plus request and reply:

// src/bookmarks/page.route.js
export const vars = { title: 'Bookmarks', layout: 'root' }

export default async function ({ vars, request }) {
  const user = request.user  // set by a Fastify hook
  const bookmarks = await fetchBookmarks(user.id)
  return `<ul>${bookmarks.map(b => `<li><a href="${b.url}">${b.title}</a></li>`).join('')}</ul>`
}

The request object contains:

Property Type Description
method string HTTP method (GET, POST, …)
url string Full request URL
params Record<string, string> Route params (e.g. { id: '123' })
query Record<string, string> Parsed query string
headers Record<string, string | string[]> Request headers

Static pages (page.js)

page.js files are always pre-rendered at startup and written to dest/. No Fastify route is registered for them — @fastify/static serves them directly.

// src/about/page.js
export const vars = { title: 'About' }

export default async function ({ vars }) {
  return `<h1>${vars.siteName}</h1><p>This is rendered once at startup.</p>`
}

In watch mode, static pages are re-rendered whenever a page or global file changes.

MD and HTML pages

Markdown and HTML pages work the same as in @domstack/static — variables are available via Handlebars template blocks. Directory-index page.md and page.html files are always pre-rendered as static files. The request context is not available in markdown or HTML pages.

Loose markdown pages

Any .md file that isn't named page.md or README.md is a loose markdown page. It gets a Fastify route at its filename stem (without the .html extension):

src/notes.md          → GET /notes
src/blog/intro.md     → GET /blog/intro

Route mapping

Page file Behavior
page.route.js (root) GET /
bookmarks/page.route.js GET /bookmarks
blog/_id/page.route.js GET /blog/:id
about/page.js Static file: dest/about/index.html
notes.md (loose) GET /notes
blog/intro.md (loose) GET /blog/intro

Segments starting with _ become Fastify route params (:paramName). The resulting params are available on request.params in the page function.

Variable cascade

Variables cascade from global to page level, just like in @domstack/static:

global.vars.js → page.vars.js → page frontmatter / page.vars export
// src/global.vars.js
export default {
  siteName: 'My Site',
  lang: 'en',
}
// src/blog/page.route.js
export const vars = {
  title: 'Blog',       // overrides nothing; adds `title`
  layout: 'blog',      // selects the "blog" layout
}

export default async function ({ vars, request }) {
  return `<h1>${vars.siteName}</h1>` // siteName comes from global.vars.js
}

Per-page route options

page.route.js files can export a routeOptions function to customize the Fastify route options object for that specific page. This lets you attach hooks, add config values, apply constraints, or do anything else Fastify's RouteShorthandOptions supports — scoped to one page.

// src/admin/page.route.js

/** @param {import('@domstack/fastify').RouteOptionsHook} options */
export function routeOptions (options) {
  return {
    ...options,
    config: { auth: true, role: 'admin' },
    constraints: { host: 'admin.example.com' },
  }
}

export default function ({ vars }) {
  return `<h1>Admin</h1>`
}

The function receives the base options object (which already contains schema if you exported one) and must return the final options. It can be async.

// src/hooks/page.route.js — add a response hook to one page

export function routeOptions (options) {
  return {
    ...options,
    onSend (req, reply, payload, done) {
      reply.header('cache-control', 'no-store')
      done(null, payload)
    },
  }
}

The following fields are managed by @domstack/fastify and should not be overridden in routeOptions:

Field Reason
url Set from the directory structure
method Always GET — pages are read-only
handler Always the domstack render handler

Everything else in RouteShorthandOptions is available: schema, config, constraints, onRequest, preHandler, onSend, preSerialization, logLevel, exposeHeadRoute, errorHandler, etc.

Note: routeOptions is read at startup during route registration. Changes to it require a server restart (same as schema).

Layouts

Layouts work the same as in @domstack/static. The default layout is the built-in root layout. Custom layouts live anywhere in src and are named *.layout.{js,ts}.

// src/layouts/blog.layout.js
export default function ({ vars, scripts, styles, children }) {
  return `<!DOCTYPE html>
<html lang="${vars.lang}">
<head>
  <title>${vars.title}</title>
  ${styles}
</head>
<body>
  <nav>...</nav>
  ${children}
  ${scripts}
</body>
</html>`
}

Templates

Files named *.template.js (or .ts) generate arbitrary output files at startup. They're ideal for RSS feeds, sitemaps, robots.txt, or any file that needs access to the full page list.

// src/sitemap.template.js
export default async function ({ vars, pages, template }) {
  const urls = pages.map(p => `  <url><loc>/${p.pageInfo.path}</loc></url>`).join('\n')
  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
}

The template function receives { vars, pages, template } and can return:

Return type Description
string Written to dest/ using the template's filename stem (e.g. sitemap.template.jsdest/sitemap)
{ outputName, content } Written to dest/ with a custom filename
[{ outputName, content }, …] Multiple output files
AsyncIterable<{ outputName, content }> Streamed output files (for large sets)

Template output is written to dest/ at startup, then served as static files by @fastify/static. In watch mode, templates are re-run whenever the template file or any page/global data file changes.

Global files

File Description
global.vars.js Site-wide variables available to all pages and layouts
global.data.js Derive and aggregate data from all pages before rendering
global.css Global stylesheet loaded on every page
global.client.js Global client script loaded on every page
esbuild.settings.js Customize esbuild bundler settings
markdown-it.settings.js Customize the markdown-it renderer

Watch mode

In development, enable watch mode to rebuild on file changes:

await fastify.register(domstackFastify, {
  src: 'src',
  dest: 'public',
  watch: true,
})

Watch mode uses chokidar to detect changes and applies the minimal rebuild needed:

Change Action
global.vars.*, esbuild.settings.* Full rebuild (re-identify pages, restart esbuild, respawn render worker, re-render static pages, rebuild templates)
global.data.*, page files, layout files Page rebuild (respawn render worker + re-render static pages + rebuild templates)
*.template.* files Template/static rebuild (re-render static pages + rebuild templates, no render worker respawn)
Client bundles, stylesheets esbuild handles rebundling automatically

Rendering uses a worker thread with an isolated ESM module cache. When a page file changes, the worker is terminated and a fresh one is spawned — so your updated modules are always re-imported without restarting the Fastify process.

License

MIT