From 53b6f11337ee910fb4a7a2c078f2300a5db51fec Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Wed, 25 Feb 2026 11:54:50 +0100 Subject: [PATCH] Update stale "realtime SSR" docs to use WASM. --- .../getting-started/first-realtime-app.mdx | 182 +++++++++++------- .../guests/typescript/eslint.config.mjs | 30 +++ .../guests/typescript/package.json | 4 + .../guests/typescript/src/index.ts | 10 +- pnpm-lock.yaml | 9 + 5 files changed, 156 insertions(+), 79 deletions(-) create mode 100644 examples/collab-clicker-ssr/guests/typescript/eslint.config.mjs diff --git a/docs/src/content/docs/getting-started/first-realtime-app.mdx b/docs/src/content/docs/getting-started/first-realtime-app.mdx index 4b3e3ffa..c7847dd3 100644 --- a/docs/src/content/docs/getting-started/first-realtime-app.mdx +++ b/docs/src/content/docs/getting-started/first-realtime-app.mdx @@ -23,7 +23,7 @@ participants.
-The conclusion of this tutorial is part of the main code repository and can be found +The conclusion of this tutorial is checked into the repository and can be found here or downloaded by running: @@ -75,14 +75,16 @@ The final command, will start an [express.js](https://expressjs.com/) development server run by Node.js as defined by `/server.js`. Opening the `server.js` implementation, -marginally simplified it looks something like: +slightly simplified it looks something like: ```ts async function handler(req, res) { const render = (await import('./dist/server/entry-server.js')).render; + // 1. Call render function. const rendered = await render(req.url); + // 2. Build HTML template. const html = templateHtml .replace(``, generateHydrationScript()) .replace(``, rendered.html); @@ -94,25 +96,25 @@ async function handler(req, res) { The details are not super important and framework-dependent but in simple terms: 1. The server first loads and executes a render function defined in `dist/server/entry-server.js`. -2. Then cuts up an - HTML template - and string-replaces two parts: - * places a serialized "hydration" script into the header, - * and puts the output of the render function as the body of the HTML document. +2. Then slices up the + `/server.js` + HTML template and inserts two parts: + * a serialized "hydration" script in the header, + * and the output of the above render function as the document body. -With the actual rendering delegated to the respective JS framework, this is -really all that's needed for SSR. -In other words, the above steps is all our TrailBase JS/TS handler will have to -do. +With this express.js server setup and the actual rendering delegated to the +respective JS framework, this is really all there is to SSR. +In other words, our TrailBase JS/TS setup will simply need to execute the +render function, then build and serve the HTML template. -Before we start plumbing it might be worth pointing out that everything under -`dist/` are artifacts vite produces from their respective counterparts under -`src/`. -For example, we can take a look at the above render function peeking into -`src/entry-server.tsx`. Besides the `dist/server` artifacts, there are -`dist/client` artifacts. These are simply static content (HTML, CSS, JS) -intended for the user's browser, which we can serve using -`trail run --public-dir=` without further intervention. +Before we start plumbing, it might be worth taking a quick look at the +directory structure: `src/` is where our application code lives, which will be built +into the final artifacts under `dist/` by `pnpm build`. +For example, the render function is defined in `src/entry-server.tsx` and will +be compiled into `/dist/server/entry-server.js`. +Besides the `dist/server` artifacts, there are `dist/client` artifacts +containing the HTML, CSS and JS intended for the user's browser. +We can serve these simply using `trail run --public-dir=`. ## Implementing the SSR Handler @@ -127,6 +129,8 @@ const htmlBody : string = render(/*...*/); to put the body along some hydration script into our `/index.html` template. +### Prep + Let first address a minor obstacle. Running `pnpm build` and looking at the `dist/server/entry-server.js` artifact we can see that the code is non-hermetic depending on framework modules. @@ -145,50 +149,67 @@ export default defineConfig({ Running `pnpm build` again, we have a standalone renderer without external dependencies 👍. -We can now implement the handler in an existing TrailBase setup (or simply run -`trail run` once before to generate a `traildepot` directory) by creating a -`traildepot/scripts/main.ts` + +### Building a minimal SSR App + +We can now start implementing the actual HTTP handler as a WASM component, +which can be run by TrailBase. +It's probably easiest to copy an existing WASM component, e.g. +`/examples/wasm-guest-ts`.[^1] + + +Stripping out all the example code, and reproducing the above behavior we end +up with something like: ```ts -import { addRoute, htmlHandler, fs } from "../trailbase.js"; -import { render } from "./entry-server.js"; +import { defineConfig } from "trailbase-wasm"; +import { HttpHandler, HttpRequest } from "trailbase-wasm/http"; +import { readFileSync } from "trailbase-wasm/fs"; -/// Register a root handler. -addRoute( - "GET", - "/", - htmlHandler(async (req) => { - const template = await fs.readTextFile('dist/client/index.html'); +// IMPORTANT: update to point to your `/dist` +// This assumes that the WASM guest lives in: +// /guests/typescript/ +import { render } from "../../../dist/server/entry-server.js"; - const rendered = render(req.uri, count); +async function ssr(req: HttpRequest): Promise { + // 1. Call render function. + const rendered = render(req.url()); - const html = template - .replace(``, rendered.head ?? '') - .replace(``, rendered.html ?? ''); + // 2. Build HTML template. + const template = new TextDecoder().decode( + readFileSync("/dist/client/index.html")); - return html; - }), -); + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? ""); + + return html; +} + +export default defineConfig({ + httpHandlers: [HttpHandler.get("/", ssr)], +}); ``` -Voila! Last things to do, satisfy the implicit dependencies: +Voila! Last thing, if you copied the WASM guest template to it's own location, +make sure that the `trailbase-wasm` dependency isn't a workspace dependency but +references the latest upstream +[package](https://www.npmjs.com/package/trailbase-wasm). +Afterwards, you should be able to run `pnpm build` to build a +`dist/component.wasm`. -1. Satisfy the import by copying `dist/server/entry-server.js` to `traildepot/scripts/`. -2. Satisfy the template loading by ensuring `dist/client/index.html` is a valid - path relative to the `trail` process' current working directory or update the - path. +After placing this component into your `/traildepot/wasm` and +starting with: -Lastly we need to pass the correct path to the client HTML, CSS and JS -artifacts as `--public-path`: - -```bash +```sh +cd trail --data-dir=traildepot run --public-dir=dist/client ``` -With that, we have our SSR app running with TrailBase 🎉. +, we have a simple SSR app running with TrailBase 🎉. -## Building the Actual App +## Building the Clicker App If SSR is what you're after, the tutorial could end here. If you have a few more minutes, let's build something a little more fun: a clicker app to @@ -198,7 +219,7 @@ For this we need a few foundational pieces: 1. Create a table with a single record holding the current global counter value. 2. A way to forward the counter state from the initial server-side render to - the client in a structured manner so we can continue to increment it[^1]. + the client in a structured manner so we can continue to increment it[^2]. 3. An API to increment the counter. 4. Add the actual button and a subscription to get notified whenever someone else increments the counter. @@ -282,21 +303,23 @@ hydrate( We also need to update the handler code to query the current count and wire it into the server-side render function above: -```ts {5-11} -addRoute( - "GET", - "/", - htmlHandler(async (req) => { - const rows = await query( - "SELECT value FROM counter WHERE id = 1", - [], - ) +```ts {4-8, 13} +import { query } from "trailbase-wasm/db"; - const count = rows.length > 0 ? rows[0][0] as number : 0; - const rendered = render(req.uri, count); +async function ssr(req: HttpRequest): Promise { + const rows = await query( + "SELECT value FROM counter WHERE id = 1", []); - // ... template stitching code - })); + const count = rows.length > 0 ? (rows[0][0] as number) : 0; + const rendered = render(req.url(), count); + + const html = readTemplate() + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, rendered.data ?? ""); + + return html; +} ``` ### 3. Counter Increment API @@ -308,19 +331,24 @@ Instead we want an atomic database update. For this we create a new TS endpoint in `traildepot/scripts/main.ts`: ```ts -addRoute( - "GET", - "/clicked", - jsonHandler(async (_req) => { - const rows = await query( - "UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value", - [], - ) +import { query } from "trailbase-wasm/db"; - const count = rows.length > 0 ? rows[0][0] as number : -1; - return { count }; - }), -); +async function clicked(_req: HttpRequest): Promise { + const rows = await query( + "UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value", + [], + ); + + const count = rows.length > 0 ? (rows[0][0] as number) : -1; + return JSON.stringify({ count }); +} + +export default defineConfig({ + httpHandlers: [ + HttpHandler.get("/clicked", clicked), + HttpHandler.get("/", ssr), + ], +}); ``` We'll see in the following paragraph how this endpoint can be called from @@ -429,5 +457,11 @@ Thanks! ---- [^1]: + For this tutorial we're going to use the TypeScript template, however the checked-in + example does also contain an implementation using Rust embedding + QuickJS to execute the render function. + +[^2]: We could technically parse the value out of the rendered DOM, though we'd like to be a bit more robust against future changes to the DOM. diff --git a/examples/collab-clicker-ssr/guests/typescript/eslint.config.mjs b/examples/collab-clicker-ssr/guests/typescript/eslint.config.mjs new file mode 100644 index 00000000..6440a3d5 --- /dev/null +++ b/examples/collab-clicker-ssr/guests/typescript/eslint.config.mjs @@ -0,0 +1,30 @@ +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default [ + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/", "node_modules/"], + }, + { + files: ["src/**/*.{js,mjs,cjs,mts,ts,tsx,jsx}"], + rules: { + // https://typescript-eslint.io/rules/no-explicit-any/ + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-wrapper-object-types": "warn", + "@typescript-eslint/no-namespace": "off", + "no-var": "off", + // http://eslint.org/docs/rules/no-unused-vars + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "after-used", + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, +]; diff --git a/examples/collab-clicker-ssr/guests/typescript/package.json b/examples/collab-clicker-ssr/guests/typescript/package.json index 45f73f94..da2f1529 100644 --- a/examples/collab-clicker-ssr/guests/typescript/package.json +++ b/examples/collab-clicker-ssr/guests/typescript/package.json @@ -9,6 +9,7 @@ "build:wasm": "jco componentize dist/index.mjs -w ../../../../guests/typescript/wit -o dist/component.wasm", "build:wasm:aot": "jco componentize dist/index.mjs -w ../../../../guests/typescript/wit --aot -o dist/component.wasm", "build": "npm run build:tsc && npm run build:wasm", + "check": "tsc --noEmit --skipLibCheck && eslint", "format": "prettier -w src" }, "dependencies": { @@ -16,9 +17,12 @@ }, "devDependencies": { "@bytecodealliance/jco": "^1.17.0", + "@eslint/js": "^10.0.1", "@types/node": "^25.2.3", + "eslint": "^10.0.0", "prettier": "^3.8.1", "typescript": "^5.9.3", + "typescript-eslint": "^8.56.0", "vite": "^7.3.1" } } diff --git a/examples/collab-clicker-ssr/guests/typescript/src/index.ts b/examples/collab-clicker-ssr/guests/typescript/src/index.ts index 16fd2520..2fd6dd80 100644 --- a/examples/collab-clicker-ssr/guests/typescript/src/index.ts +++ b/examples/collab-clicker-ssr/guests/typescript/src/index.ts @@ -1,10 +1,10 @@ import { defineConfig } from "trailbase-wasm"; -import { HttpHandler, Request } from "trailbase-wasm/http"; +import { HttpHandler, HttpRequest } from "trailbase-wasm/http"; import { query } from "trailbase-wasm/db"; import { readFileSync } from "trailbase-wasm/fs"; import { Store } from "trailbase-wasm/kv"; -// @ts-ignore +// @ts-expect-error: just trying to avoid aliases. import { render } from "../../../dist/server/entry-server.js"; function readTemplate(): string { @@ -30,7 +30,7 @@ function readCachedFileSync(path: string): Uint8Array { return contents; } -async function clicked(_: Request): Promise { +async function clicked(_req: HttpRequest): Promise { const rows = await query( "UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value", [], @@ -40,11 +40,11 @@ async function clicked(_: Request): Promise { return JSON.stringify({ count }); } -async function ssr(_: Request): Promise { +async function ssr(req: HttpRequest): Promise { const rows = await query("SELECT value FROM counter WHERE id = 1", []); const count = rows.length > 0 ? (rows[0][0] as number) : 0; - const rendered = render("ignored", count); + const rendered = render(req.url(), count); const html = readTemplate() .replace(``, rendered.head ?? "") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88e4476d..9cb07efb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,15 +773,24 @@ importers: '@bytecodealliance/jco': specifier: ^1.17.0 version: 1.17.0 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.0(jiti@2.6.1)) '@types/node': specifier: ^25.2.3 version: 25.2.3 + eslint: + specifier: ^10.0.0 + version: 10.0.0(jiti@2.6.1) prettier: specifier: ^3.8.1 version: 3.8.1 typescript: specifier: ^5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.56.0 + version: 8.56.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)