Update stale "realtime SSR" docs to use WASM.

This commit is contained in:
Sebastian Jeltsch
2026-02-25 11:54:50 +01:00
parent ba5ae3f3a5
commit 53b6f11337
5 changed files with 156 additions and 79 deletions
@@ -23,7 +23,7 @@ participants.
<div class="h-[24px]" />
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
<a href={githubPath("examples/collab-clicker-ssr")}>here</a>
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 `<project-name>/server.js`.
Opening the
<a href={`${createViteBase}/template-ssr-solid-ts/server.js#L41-L63`}>`server.js` implementation</a>,
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(`<!--app-head-->`, generateHydrationScript())
.replace(`<!--app-html-->`, 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
<a href={`${createViteBase}/template-ssr-solid-ts/index.html`}>HTML template</a>
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
<a href={`${createViteBase}/template-ssr-solid-ts/index.html`}>`<project-name>/server.js`</a>
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=<client-artifacts-path>` 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=<client-artifacts-path>`.
## Implementing the SSR Handler
@@ -127,6 +129,8 @@ const htmlBody : string = render(/*...*/);
to put the body along some hydration script into our
`<project-name>/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 `<project-name>/dist`
// This assumes that the WASM guest lives in:
// <project-name>/guests/typescript/
import { render } from "../../../dist/server/entry-server.js";
const rendered = render(req.uri, count);
async function ssr(req: HttpRequest): Promise<string> {
// 1. Call render function.
const rendered = render(req.url());
const html = template
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '');
// 2. Build HTML template.
const template = new TextDecoder().decode(
readFileSync("/dist/client/index.html"));
return html;
}),
);
const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, 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 `<project-name>/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 <project_name>
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<string> {
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(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "")
.replace(`<!--app-data-->`, 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<string> {
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 <a
href={githubPath("examples/collab-clicker-ssr/guests/rust")}>Rust</a> 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.
@@ -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: "^_",
},
],
},
},
];
@@ -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"
}
}
@@ -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<string> {
async function clicked(_req: HttpRequest): Promise<string> {
const rows = await query(
"UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value",
[],
@@ -40,11 +40,11 @@ async function clicked(_: Request): Promise<string> {
return JSON.stringify({ count });
}
async function ssr(_: Request): Promise<string> {
async function ssr(req: HttpRequest): Promise<string> {
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(`<!--app-head-->`, rendered.head ?? "")
+9
View File
@@ -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)