recipes/posts-to-pages/main.ts
One file of recipes/posts-to-pages, at commit 9a5bb24.
// Pages written while the application is running, not only while it is being built.
//
// The job: a small publication. Someone publishes a post over a socket, the module that takes it
// puts the row in the store and writes that one page, and the page is live and hydrating before
// the call has answered. A scheduled full write refreshes everything, which is how the sitemap
// learns about the posts published since the last one.
//
// Run: AWEFT_DEFAULT_H=@aweftjs/ui node --import @aweftjs/build/loader recipes/posts-to-pages/main.ts
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { chromium } from 'playwright';
import WebSocket from 'ws';
import { createArray, createObject } from '@aweftjs/core';
import { createScheduler } from '@aweftjs/jobs';
import type { Clock, Job } from '@aweftjs/jobs';
import { fromBundle } from '@aweftjs/modules';
import type { ModuleExports, ModuleProps } from '@aweftjs/modules';
import { createServer as createAweftServer, open } from '@aweftjs/server';
import { node } from '@aweftjs/server/node';
import { createSite } from '@aweftjs/ssg';
import type { Site as SiteHandle } from '@aweftjs/ssg';
import { createStore, memoryDriver } from '@aweftjs/store';
import { requests } from '@aweftjs/sync';
import type { SocketLike } from '@aweftjs/sync';
import { h } from '@aweftjs/ui';
import { Site } from './site.tsx';
import type { Post } from './site.tsx';
const here = fileURLToPath(new URL('.', import.meta.url));
const dist = join(here, 'dist');
// --- the bundle and the shell ---------------------------------------------------------------------
console.log('building the client bundle');
await build({ configFile: join(here, 'vite.config.ts'), logLevel: 'warn' });
const shell = readFileSync(join(dist, 'index.html'), 'utf8');
// --- the store: the posts, as a document -----------------------------------------------------------
const store = createStore({ driver: memoryDriver() });
const handle = await store.open('posts', 'array');
const rows = handle.root as Post[];
/** The rows as the page reads them: plain objects, taken fresh for every render. */
const postsNow = (): Post[] =>
[...rows].map((row) => ({ id: String(row.id), title: String(row.title), body: String(row.body) }));
// The browser needs the same rows the render had, before it hydrates, so they are written beside
// the pages and `entry.tsx` reads them first. A component that waited on the server would otherwise
// wait again on the client and render its loading state over the finished page.
const writeRows = (): void => {
writeFileSync(join(dist, 'posts.json'), JSON.stringify(postsNow()), 'utf8');
};
const site = createSite({
page: (router) => h(Site, { router, posts: postsNow() }),
shell,
out: dist,
base: 'https://publication.example',
});
// --- the module that publishes -----------------------------------------------------------------------
// Two things this program made before there was a server, and one module that needs both.
// Anything the application makes is a module that others `deps` on (design 240): there is
// nowhere else to put them, and the loader orders them for us.
const app = fromBundle({
'./posts/Rows.ts': { default: () => rows } satisfies ModuleExports,
'./posts/Site.ts': { default: () => site } satisfies ModuleExports,
// Public, because this recipe has no accounts in it. A real one puts the gate in front.
'./posts/Publish.ts': {
deps: ['posts/Rows', 'posts/Site'],
default: ({ imports }: ModuleProps) => ({
public: true,
call: async (args: unknown) => {
const posts = imports.Rows as Post[];
const pages = imports.Site as SiteHandle;
const { id, title, body } = args as Post;
posts.push(createObject<Post>({ id, title, body }) as Post);
writeRows();
// `posts/:id` matches any id, so `ssg` cannot tell a real slug from a typo: only this
// module knows which rows exist. The row went in above, so the page is a page.
assert.ok([...posts].some((row) => String(row.id) === id), 'the row is in the store');
// One page, not the site. A publish inside a request cannot afford to render
// everything the site has, and the sitemap is a full write's business (design 150).
const written = await pages.write([`/posts/${id}`]);
return written.files;
},
}),
} satisfies ModuleExports,
});
const listener = node({ port: 0, host: '127.0.0.1' });
const backend = createAweftServer({ sources: [app], gate: open, listener });
await backend.start();
// --- publishing one post ------------------------------------------------------------------------------
writeRows();
assert.ok(!existsSync(join(dist, 'posts', 'first-light', 'index.html')), 'nothing is published yet');
const socket = new WebSocket(`ws://127.0.0.1:${listener.port!}/`);
const asks = requests(socket as unknown as SocketLike);
const files = await asks.ask('posts/Publish', {
id: 'first-light',
title: 'First light',
body: 'The first post this publication ever had.',
});
assert.deepEqual(files, ['posts/first-light/index.html'], 'the module wrote exactly the page it published');
const published = readFileSync(join(dist, 'posts', 'first-light', 'index.html'), 'utf8');
assert.match(published, /<title[^>]*>First light<\/title>/, 'the page carries the post\'s title');
assert.ok(published.includes('The first post this publication ever had.'), 'and its body');
assert.ok(published.includes('data-aweft-ssg'), 'and it is stamped, so a browser takes it over');
assert.ok(!existsSync(join(dist, 'sitemap.xml')), 'and no sitemap yet: a list writes those pages and nothing else');
console.log(' a post pushed over a socket became a page');
socket.close();
await new Promise<void>((done) => { socket.once('close', () => done()); });
await backend.stop();
// --- the page, in a real browser -------------------------------------------------------------------------
const TYPES: Record<string, string> = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.xml': 'application/xml',
};
// `server`'s routes are exact, so serving a directory through it would be a route per file. A host
// serves the files; this is the smallest one that can.
const host = createServer((request, response) => {
const path = decodeURIComponent((request.url ?? '/').split('?')[0]!);
const asked = join(dist, normalize(path));
const tries = asked === dist || asked.startsWith(`${dist}/`)
? [asked, join(asked, 'index.html'), join(dist, 'shell.html'), join(dist, '404.html')]
: [];
for (const file of tries) {
if (!existsSync(file) || !statSync(file).isFile()) continue;
response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
response.end(readFileSync(file));
return;
}
response.writeHead(404).end();
});
await new Promise<void>((done) => host.listen(0, '127.0.0.1', done));
const port = (host.address() as { port: number }).port;
const browser = await chromium.launch();
const view = await browser.newPage();
const problems: string[] = [];
view.on('pageerror', (error) => problems.push(String(error)));
try {
await view.goto(`http://127.0.0.1:${port}/posts/first-light`);
await view.waitForSelector('#post');
assert.equal(await view.textContent('#post-title'), 'First light');
assert.equal(await view.title(), 'First light', 'the head tags in the file are the document\'s');
// The page is live: the router takes a link click rather than the browser fetching a document.
assert.equal(await view.getAttribute('#post', 'data-post'), 'first-light');
assert.deepEqual(problems, [], 'and the hydration found the two renders agreeing');
console.log(' and the page hydrates in a browser with no mismatch');
} finally {
await browser.close();
await new Promise<void>((done) => { host.close(() => done()); });
}
// --- the scheduled full write --------------------------------------------------------------------------
const HOUR = 3_600_000;
/** An operator's clock, so a nightly job can be watched without waiting until night. */
const driven = (start: number) => {
let now = start;
let id = 0;
const timers = new Map<number, { at: number; fn: () => void }>();
const clock: Clock = {
now: () => now,
setTimeout: (fn, ms) => { timers.set(++id, { at: now + ms, fn }); return id; },
clearTimeout: (held) => { timers.delete(held as number); },
};
const breathe = async (): Promise<void> => { for (let i = 0; i < 6; i += 1) await new Promise((r) => setImmediate(r)); };
const advance = async (ms: number): Promise<void> => {
const target = now + ms;
for (;;) {
let next: [number, { at: number; fn: () => void }] | undefined;
for (const pair of timers) if (pair[1].at <= target && (next === undefined || pair[1].at < next[1].at)) next = pair;
if (next === undefined) break;
now = Math.max(now, next[1].at);
timers.delete(next[0]);
next[1].fn();
await breathe();
}
now = target;
await breathe();
};
return { clock, advance };
};
const { clock, advance } = driven(Date.UTC(2026, 8, 7, 3, 0));
const jobs = createArray<Job>([createObject<Job>({ kind: 'refresh', every: HOUR })]);
let refreshes = 0;
const scheduler = createScheduler({
jobs,
clock,
run: async () => { refreshes += 1; writeRows(); await site.write(); },
});
await advance(HOUR);
await scheduler.stop();
assert.equal(refreshes, 1, 'the scheduled refresh ran once in the hour');
const sitemap = readFileSync(join(dist, 'sitemap.xml'), 'utf8');
assert.ok(sitemap.includes('<loc>https://publication.example/posts/first-light</loc>'),
'and the full write put the published post in the sitemap');
assert.ok(!sitemap.includes('/missing'), 'while the noindex page stayed out of it');
assert.ok(existsSync(join(dist, '404.html')) && existsSync(join(dist, 'shell.html')),
'and the full write is what makes the 404 and the live shell');
await store.close(handle);
await store.stop();
console.log('recipes/posts-to-pages: ok');