toiljs 0.0.74 → 0.0.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TYPESCRIPT_LAW.md +1 -3
  3. package/build/client/.tsbuildinfo +1 -1
  4. package/build/client/components/Image.js +11 -14
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/generate.js +9 -0
  7. package/build/compiler/template-build.d.ts +4 -0
  8. package/build/compiler/template-build.js +11 -1
  9. package/build/devserver/.tsbuildinfo +1 -1
  10. package/build/devserver/runtime/module.js +2 -0
  11. package/build/devserver/stream/index.d.ts +2 -1
  12. package/build/devserver/stream/index.js +3 -11
  13. package/build/devserver/stream/manager.d.ts +2 -0
  14. package/build/devserver/stream/manager.js +7 -6
  15. package/build/devserver/stream/ws.js +4 -0
  16. package/package.json +2 -9
  17. package/src/client/components/Image.tsx +18 -13
  18. package/src/compiler/generate.ts +16 -0
  19. package/src/compiler/template-build.ts +32 -1
  20. package/src/devserver/runtime/module.ts +3 -0
  21. package/src/devserver/stream/index.ts +8 -19
  22. package/src/devserver/stream/manager.ts +15 -11
  23. package/src/devserver/stream/ws.ts +2 -0
  24. package/test/assembly/aspect-shim.ts +33 -0
  25. package/test/assembly/cookie.spec.ts +1 -0
  26. package/test/assembly/example.spec.ts +1 -0
  27. package/test/assembly/ssr.spec.ts +1 -0
  28. package/test/assembly.test.ts +59 -0
  29. package/test/dom/Image.test.tsx +25 -2
  30. package/test/fixtures/stream-gate.ts +19 -15
  31. package/test/ssr-template.test.tsx +54 -0
  32. package/test/stream-emulation.test.ts +56 -10
  33. package/vitest.config.ts +2 -1
  34. package/as-pect.asconfig.json +0 -34
  35. package/as-pect.config.js +0 -71
@@ -404,6 +404,15 @@ const DEFAULT_HTML =
404
404
  /** The module entry that boots the app, injected into the HTML (resolved relative to `.toil`). */
405
405
  const ENTRY_SCRIPT = `<script type="module" src="./entry.tsx"></script>`;
406
406
 
407
+ /** Toil's component base CSS, baked into the shell `<head>` so built-in components (the `Image`
408
+ * `fill`/blur layout) style via overridable classes instead of forced-inline styles, and stay
409
+ * SSR-safe (present before JS). The app can restyle `.toil-img-*` freely. */
410
+ const TOIL_BASE_STYLE =
411
+ `<style id="toil-base">` +
412
+ `.toil-img-fill{position:absolute;inset:0;width:100%;height:100%}` +
413
+ `.toil-img-blur{background-size:cover;background-position:center;filter:blur(20px)}` +
414
+ `</style>`;
415
+
407
416
  /**
408
417
  * Produces the `.toil/index.html` Vite entry from the project's `public/index.html` template (or
409
418
  * the built-in default if absent), ensuring the generated module entry script is present. Users
@@ -419,6 +428,13 @@ function buildHtml(cfg: ResolvedToilConfig): string {
419
428
  ? html.replace('</body>', ` ${ENTRY_SCRIPT}\n </body>`)
420
429
  : `${html}\n${ENTRY_SCRIPT}\n`;
421
430
  }
431
+ // Ship toil's component base CSS once, so `Image` `fill`/blur lay out via overridable classes
432
+ // instead of inline styles. Idempotent (keyed on the `toil-base` id).
433
+ if (!html.includes('id="toil-base"')) {
434
+ html = html.includes('</head>')
435
+ ? html.replace('</head>', ` ${TOIL_BASE_STYLE}\n </head>`)
436
+ : `${TOIL_BASE_STYLE}\n${html}`;
437
+ }
422
438
  return html;
423
439
  }
424
440
 
@@ -45,6 +45,8 @@ import {
45
45
  extractFromHtml,
46
46
  } from './template.js';
47
47
  import { createViteConfig } from './vite.js';
48
+ import { extractStaticMetadata, loadTypeScript } from './prerender.js';
49
+ import { injectSeoHtml, routeSeo, type SeoConfig } from './seo.js';
48
50
 
49
51
  /** Marker element the client `mount` looks for to switch to `hydrateRoot`. */
50
52
  const SSR_MARKER = '<template id="__toil_ssr"></template>';
@@ -78,6 +80,14 @@ export interface RouteRenderInput {
78
80
  /** React's `Suspense` from the SAME instance as {@link createElement}, so the
79
81
  * Suspense dehydration markers (`<!--$-->`) emitted match the client's. */
80
82
  Suspense?: typeof Suspense;
83
+ /** Site SEO config. When set, this route's resolved SEO (site defaults overlaid with the
84
+ * route's static `metadata`) is baked into the template `<head>`, so an SSR route serves the
85
+ * same per-page title/description/og (incl og:image) a crawler gets from `<route>/index.html`. */
86
+ seo?: SeoConfig | null;
87
+ /** The route's static `export const metadata` (extracted at build), overlaid on the site SEO. */
88
+ metadata?: Record<string, unknown> | null;
89
+ /** The route pattern, used for the canonical / `og:url`. */
90
+ pattern?: string;
81
91
  }
82
92
 
83
93
  export interface TemplateArtifacts {
@@ -162,7 +172,16 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
162
172
  } finally {
163
173
  input.setSsrBuild(false);
164
174
  }
165
- const full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
175
+ let full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
176
+ // Bake the route's resolved SEO into the template <head>, mirroring the static prerender
177
+ // (prerender.ts / ssg.ts) so an `ssr=true` route serves the SAME per-page metadata (title,
178
+ // description, canonical, og:* incl og:image, twitter, jsonLd) a crawler gets from the static
179
+ // <route>/index.html — which the dynamic SSR template otherwise shadows at request time. Runs on
180
+ // the FULL spliced document (injectSeoHtml's <title>/</head> regexes need the shell head, not the
181
+ // stripped fragment) and BEFORE extractFromHtml so the coherence hash covers the baked head.
182
+ if (input.seo) {
183
+ full = injectSeoHtml(full, routeSeo(input.seo, input.metadata ?? null, input.pattern ?? '/'));
184
+ }
166
185
  const extracted: Extracted = extractFromHtml(full);
167
186
  const ids = assignSlotIds(extracted.slots);
168
187
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -259,6 +278,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
259
278
  const routes = scanRoutes(cfg.routesAbsDir).filter((r) => r.slot === undefined && !r.intercept);
260
279
  if (routes.length === 0) return [];
261
280
 
281
+ // Load TypeScript once (same as prerender.ts) to read each route's static `metadata` for the
282
+ // SSR <head>. Only needed when the project configures SEO.
283
+ const ts = cfg.seo ? await loadTypeScript(cfg.root) : null;
284
+
262
285
  const warn = (msg: string): void => {
263
286
  process.stderr.write(` toil: SSR ${msg}\n`);
264
287
  };
@@ -332,6 +355,11 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
332
355
  layouts.push(lm.default);
333
356
  }
334
357
 
358
+ // The route's static `metadata` export (same static-AST read prerender.ts uses, so
359
+ // the SSR head matches the static <route>/index.html exactly). generateMetadata is
360
+ // dynamic and skipped here, as in prerender/ssg.
361
+ const metadata = ts ? extractStaticMetadata(ts, r.file) : null;
362
+
335
363
  const name = routeTemplateName(r.pattern);
336
364
  // Tell location hooks which URL this template is for, so a NavLink's active
337
365
  // class / aria-current render as they will on the client at this route (else
@@ -348,6 +376,9 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
348
376
  createElement: react.createElement,
349
377
  renderToString: reactDomServer.renderToString,
350
378
  Suspense: react.Suspense,
379
+ seo: cfg.seo,
380
+ metadata,
381
+ pattern: r.pattern,
351
382
  });
352
383
  rendered.push({ pattern: r.pattern, art });
353
384
  } catch (err) {
@@ -110,6 +110,9 @@ const PROVIDED_IMPORTS = new Set([
110
110
  'email_send',
111
111
  'env_get',
112
112
  'env_get_secure',
113
+ // Per-domain analytics (see ./analytics/index.ts).
114
+ 'analytics_read',
115
+ 'analytics_list_sites',
113
116
  // Web Crypto host functions (see ./crypto.ts).
114
117
  'crypto.fill_random',
115
118
  'crypto.random_uuid',
@@ -81,10 +81,10 @@ export type StreamMessageOutcome =
81
81
  | { readonly kind: 'reply'; readonly frames: Buffer[] }
82
82
  | { readonly kind: 'reject'; readonly code: number };
83
83
 
84
- /** A `@connect` outcome: the guest accepted (the box is usable) or rejected with a `0x02xx` code. */
84
+ /** A `@connect` outcome: accepted/rejected plus any immediate frames staged during the hook. */
85
85
  export type StreamConnectOutcome =
86
- | { readonly kind: 'accept' }
87
- | { readonly kind: 'reject'; readonly code: number };
86
+ | { readonly kind: 'accept'; readonly initialEgress: Buffer[] }
87
+ | { readonly kind: 'reject'; readonly code: number; readonly initialEgress: Buffer[] };
88
88
 
89
89
  export class DevStreamBox {
90
90
  private constructor(
@@ -132,17 +132,16 @@ export class DevStreamBox {
132
132
 
133
133
  /**
134
134
  * Fire `@connect`: write the connect-info block (stream id + transport + authority + path) into the
135
- * guest's info region, dispatch `EVENT_CONNECT`, and decode the `StreamOutbound` accept/reject. On
136
- * accept, clear any `@connect`-staged egress (initial-egress is deferred, like the edge) so it does
137
- * not contaminate the first `@message` reply. A box without the bridge runs `@connect` context-free
135
+ * guest's info region, dispatch `EVENT_CONNECT`, decode the `StreamOutbound` accept/reject, and
136
+ * drain any staged egress as initial frames. A box without the bridge runs `@connect` context-free
138
137
  * (the write is a no-op) and always accepts unless it returns a negative code.
139
138
  */
140
139
  onConnect(streamId: bigint, authority: string, path: string): StreamConnectOutcome {
141
140
  this.writeConnectInfo(streamId, authority, path);
142
141
  const rc = this.dispatch(EVENT_CONNECT, streamId);
143
- if (rc < 0n) return { kind: 'reject', code: decodeRejectCode(rc) };
144
- this.resetEgressRing();
145
- return { kind: 'accept' };
142
+ const initialEgress = this.egressDrain();
143
+ if (rc < 0n) return { kind: 'reject', code: decodeRejectCode(rc), initialEgress };
144
+ return { kind: 'accept', initialEgress };
146
145
  }
147
146
 
148
147
  /** Fire `@close` (graceful close). */
@@ -241,16 +240,6 @@ export class DevStreamBox {
241
240
  if (pathLen > 0) memU8.set(pathBytes.subarray(0, pathLen), base + SI_BODY + authLen);
242
241
  }
243
242
 
244
- /** Zero the egress ring cursors, discarding any staged frames. Safe between dispatches (the guest,
245
- * the sole egress producer, is idle). */
246
- private resetEgressRing(): void {
247
- const rings = this.rings;
248
- if (!rings) return;
249
- const dv = new DataView(this.exports.memory.buffer);
250
- dv.setUint32(rings.egressOff + RC_WRITE, 0, true);
251
- dv.setUint32(rings.egressOff + RC_READ, 0, true);
252
- }
253
-
254
243
  /** Host (producer) writes ONE inbound RingFrame into the ingress ring with the drained-reset
255
244
  * (host owns write_cursor; reset read_cursor only when the guest has drained). */
256
245
  private ingressWrite(inbound: Buffer): void {
@@ -32,8 +32,8 @@ export const STREAM_HOOK_TRAPPED = 0x0200;
32
32
 
33
33
  /** The outcome of an upgrade accept attempt (mirrors the edge `StreamUpgradeOutcome`). */
34
34
  export type StreamUpgradeOutcome =
35
- | { readonly kind: 'accepted'; readonly streamId: bigint }
36
- | { readonly kind: 'rejected'; readonly code: number };
35
+ | { readonly kind: 'accepted'; readonly streamId: bigint; readonly initialEgress: Buffer[] }
36
+ | { readonly kind: 'rejected'; readonly code: number; readonly initialEgress: Buffer[] };
37
37
 
38
38
  /** The outcome of driving one inbound frame (mirrors the edge `StreamDatagramOutcome`). */
39
39
  export type StreamDispatchResult =
@@ -69,30 +69,34 @@ export class StreamDevHost {
69
69
  * Accept (or reject) an upgrade for `authority` + `path`, mirroring `StreamWorker::accept_upgrade`:
70
70
  * (re)load the artifact on mtime change, instantiate a resident box, fire `@connect` WITH the
71
71
  * connect context, and HONOR the guest's accept/reject. Returns the host-assigned stream id on
72
- * accept, or a `0x02xx` reject code. Fails closed (no connection registered, box dropped) on a
73
- * missing/unreadable artifact (`0x0208`), a `@connect` reject (the guest's code), or a load/connect
74
- * trap (`0x0200`). Throws only on a duplicate `connId`.
72
+ * accept, plus any initial egress staged by `@connect`, or a `0x02xx` reject code. Fails closed
73
+ * (no connection registered, box dropped) on a missing/unreadable artifact (`0x0208`), a
74
+ * `@connect` reject (the guest's code), or a load/connect trap (`0x0200`). Throws only on a
75
+ * duplicate `connId`.
75
76
  */
76
77
  acceptUpgrade(connId: string, authority: string, path: string): StreamUpgradeOutcome {
77
- if (this.conns.has(connId)) throw new Error(`stream connection '${connId}' is already open`);
78
+ if (this.conns.has(connId))
79
+ throw new Error(`stream connection '${connId}' is already open`);
78
80
  this.refresh();
79
- if (!this.bytes) return { kind: 'rejected', code: STREAM_REJECTED };
81
+ if (!this.bytes) return { kind: 'rejected', code: STREAM_REJECTED, initialEgress: [] };
80
82
  let box: DevStreamBox;
81
83
  try {
82
84
  box = DevStreamBox.load(this.bytes);
83
85
  } catch {
84
- return { kind: 'rejected', code: STREAM_REJECTED };
86
+ return { kind: 'rejected', code: STREAM_REJECTED, initialEgress: [] };
85
87
  }
86
88
  const streamId = this.allocStreamId();
87
89
  let outcome;
88
90
  try {
89
91
  outcome = box.onConnect(streamId, authority, path);
90
92
  } catch {
91
- return { kind: 'rejected', code: STREAM_HOOK_TRAPPED }; // @connect trapped
93
+ return { kind: 'rejected', code: STREAM_HOOK_TRAPPED, initialEgress: [] }; // @connect trapped
94
+ }
95
+ if (outcome.kind === 'reject') {
96
+ return { kind: 'rejected', code: outcome.code, initialEgress: outcome.initialEgress };
92
97
  }
93
- if (outcome.kind === 'reject') return { kind: 'rejected', code: outcome.code };
94
98
  this.conns.set(connId, { box, streamId });
95
- return { kind: 'accepted', streamId };
99
+ return { kind: 'accepted', streamId, initialEgress: outcome.initialEgress };
96
100
  }
97
101
 
98
102
  /**
@@ -45,10 +45,12 @@ export class StreamWsSession {
45
45
  onOpen(): boolean {
46
46
  const up = this.host.acceptUpgrade(this.connId, this.authority, this.path);
47
47
  if (up.kind === 'rejected') {
48
+ for (const frame of up.initialEgress) this.transport.send(frame);
48
49
  this.transport.close(up.code);
49
50
  return false;
50
51
  }
51
52
  this.open = true;
53
+ for (const frame of up.initialEgress) this.transport.send(frame);
52
54
  return true;
53
55
  }
54
56
 
@@ -0,0 +1,33 @@
1
+ // A minimal in-AssemblyScript test shim for this directory's assembly specs.
2
+ // `describe`/`it` run their bodies inline; `expect(x).toBe(y)` / `.toStrictEqual(y)` assert
3
+ // `x == y` (value-equality, which covers the bool / i32 / string the specs compare). A failed
4
+ // assertion aborts with the current test name; the vitest runner (../assembly.test.ts) compiles
5
+ // each spec with toilscript, runs `_start`, and surfaces the abort as a test failure.
6
+
7
+ let currentTest: string = '';
8
+
9
+ export function describe(_name: string, fn: () => void): void {
10
+ fn();
11
+ }
12
+
13
+ export function it(name: string, fn: () => void): void {
14
+ currentTest = name;
15
+ fn();
16
+ }
17
+
18
+ export class Expectation<T> {
19
+ private actual: T;
20
+ constructor(actual: T) {
21
+ this.actual = actual;
22
+ }
23
+ toBe(expected: T): void {
24
+ assert(this.actual == expected, currentTest);
25
+ }
26
+ toStrictEqual(expected: T): void {
27
+ assert(this.actual == expected, currentTest);
28
+ }
29
+ }
30
+
31
+ export function expect<T>(actual: T): Expectation<T> {
32
+ return new Expectation<T>(actual);
33
+ }
@@ -5,6 +5,7 @@
5
5
  // as-pect compiler does not ship. `SecureCookies`
6
6
  // is exercised end-to-end against the real toilscript-compiled wasm in
7
7
  // `test/devserver.test.ts`.
8
+ import { describe, it, expect } from './aspect-shim';
8
9
  import { Method, Request, Header } from '../../server/runtime/request';
9
10
  import { Response } from '../../server/runtime/response';
10
11
  import { Cookie, SameSite, CookieEncoding } from '../../server/runtime/http/cookie';
@@ -1,6 +1,7 @@
1
1
  // Imports the specific modules rather than the runtime index: the index
2
2
  // re-exports `SecureCookies`, which depends on the toilscript crypto std the
3
3
  // as-pect compiler does not ship (see test/assembly/cookie.spec.ts).
4
+ import { describe, it, expect } from './aspect-shim';
4
5
  import { Method } from '../../server/runtime/request';
5
6
  import { Response } from '../../server/runtime/response';
6
7
 
@@ -3,6 +3,7 @@
3
3
  // (escaping + buffer building + linear-memory encode), so they run under
4
4
  // as-pect; the full `render` export is exercised via the example wasm in
5
5
  // test/devserver.test.ts.
6
+ import { describe, it, expect } from './aspect-shim';
6
7
  import { escapeHtml, escapeJsonForScript } from '../../server/runtime/ssr/escape';
7
8
  import { HASH_LEN, HtmlBuilder, SlotKind, SlotValues } from '../../server/runtime/ssr/slots';
8
9
  import { encodeValues } from '../../server/runtime/ssr/encode';
@@ -0,0 +1,59 @@
1
+ // Runs the AssemblyScript specs in test/assembly/*.spec.ts by compiling each with toilscript and
2
+ // executing its `_start` (which runs the describe/it bodies + the shim's asserts). A failed assert
3
+ // aborts; this runner surfaces it as a vitest failure. Runs them via toilscript, no external runner.
4
+ import { describe, it } from 'vitest';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const TOILSCRIPT_BIN = join(here, '..', 'node_modules', 'toilscript', 'bin', 'toilscript.js');
13
+ const SPECS = ['example', 'cookie', 'ssr'] as const;
14
+
15
+ function liftString(ptr: number, mem: WebAssembly.Memory): string {
16
+ if (!ptr) return '(no message)';
17
+ const u32 = new Uint32Array(mem.buffer);
18
+ const len = (u32[(ptr - 4) >>> 2] >>> 1) >>> 0;
19
+ const u16 = new Uint16Array(mem.buffer);
20
+ let s = '';
21
+ const start = ptr >>> 1;
22
+ for (let i = 0; i < len; i++) s += String.fromCharCode(u16[start + i]);
23
+ return s;
24
+ }
25
+
26
+ describe('assembly specs (toilscript-compiled)', () => {
27
+ for (const spec of SPECS) {
28
+ it(`${spec}.spec.ts passes`, async () => {
29
+ const tmp = mkdtempSync(join(tmpdir(), 'toiljs-asm-'));
30
+ try {
31
+ const src = join(here, 'assembly', `${spec}.spec.ts`);
32
+ const out = join(tmp, `${spec}.wasm`);
33
+ const r = spawnSync(
34
+ 'node',
35
+ [TOILSCRIPT_BIN, src, '-o', out, '--exportStart', '_start', '--runtime', 'stub'],
36
+ { encoding: 'utf8' },
37
+ );
38
+ if (r.status !== 0) {
39
+ throw new Error(`toilscript compile ${spec}.spec.ts failed:\n${r.stderr}${r.stdout}`);
40
+ }
41
+ const wasm = readFileSync(out);
42
+ const holder: { mem: WebAssembly.Memory | null } = { mem: null };
43
+ const { instance } = await WebAssembly.instantiate(wasm, {
44
+ env: {
45
+ abort(msg: number, _file: number, line: number): void {
46
+ const m = holder.mem ? liftString(msg >>> 0, holder.mem) : '';
47
+ throw new Error(`${spec}.spec.ts assertion failed: "${m}" (line ${line})`);
48
+ },
49
+ seed: () => 0,
50
+ },
51
+ });
52
+ holder.mem = instance.exports.memory as WebAssembly.Memory;
53
+ (instance.exports._start as () => void)();
54
+ } finally {
55
+ rmSync(tmp, { recursive: true, force: true });
56
+ }
57
+ });
58
+ }
59
+ });
@@ -23,6 +23,9 @@ describe('Image', () => {
23
23
  expect(img.getAttribute('width')).toBe('200');
24
24
  expect(img.getAttribute('height')).toBe('100');
25
25
  expect(img.getAttribute('fetchpriority')).toBe('auto');
26
+ // A plain image adds no inline style and no toil class (nothing to lay out).
27
+ expect(img.getAttribute('style')).toBe(null);
28
+ expect(img.className).toBe('');
26
29
  });
27
30
 
28
31
  it('priority images load eagerly with high fetch priority', () => {
@@ -38,7 +41,7 @@ describe('Image', () => {
38
41
  expect(img.getAttribute('fetchpriority')).toBe('high');
39
42
  });
40
43
 
41
- it('fill drops width/height and absolutely positions the image', () => {
44
+ it('fill drops width/height and lays out via the toil-img-fill class, not inline styles', () => {
42
45
  const { getByAltText } = render(
43
46
  <Image
44
47
  src="/bg.png"
@@ -50,10 +53,28 @@ describe('Image', () => {
50
53
  const img = getByAltText('bg') as HTMLImageElement;
51
54
  expect(img.hasAttribute('width')).toBe(false);
52
55
  expect(img.hasAttribute('height')).toBe(false);
53
- expect(img.style.position).toBe('absolute');
56
+ // The fill layout comes from a shipped, overridable CSS class, NOT inline positioning.
57
+ expect(img.classList.contains('toil-img-fill')).toBe(true);
58
+ expect(img.style.position).toBe('');
59
+ // objectFit is genuinely per-instance, so it stays inline.
54
60
  expect(img.style.objectFit).toBe('cover');
55
61
  });
56
62
 
63
+ it('preserves the caller className alongside the fill class', () => {
64
+ const { getByAltText } = render(
65
+ <Image
66
+ src="/bg.png"
67
+ alt="bg2"
68
+ fill
69
+ className="hero"
70
+ />,
71
+ );
72
+ const img = getByAltText('bg2') as HTMLImageElement;
73
+ expect(img.classList.contains('hero')).toBe(true);
74
+ expect(img.classList.contains('toil-img-fill')).toBe(true);
75
+ expect(img.getAttribute('style')).toBe(null);
76
+ });
77
+
57
78
  it('shows a blur placeholder until the image loads', () => {
58
79
  const { getByAltText } = render(
59
80
  <Image
@@ -66,8 +87,10 @@ describe('Image', () => {
66
87
  />,
67
88
  );
68
89
  const img = getByAltText('p') as HTMLImageElement;
90
+ expect(img.classList.contains('toil-img-blur')).toBe(true);
69
91
  expect(img.style.backgroundImage).toContain('data:image/x');
70
92
  fireEvent.load(img);
71
93
  expect(img.style.backgroundImage).toBe('');
94
+ expect(img.classList.contains('toil-img-blur')).toBe(false);
72
95
  });
73
96
  });
@@ -1,24 +1,28 @@
1
1
  // Dev @connect-bridge fixture: a `@stream('gate')` whose `@connect(c: StreamInbound): StreamOutbound`
2
2
  // reads the host-written connect context (the path) and REJECTS "/blocked" with 0x0211, ACCEPTING any
3
- // other path; a "/greet" path stages an egress frame DURING @connect (the host must clear it so it does
4
- // not contaminate the first @message reply). A @message echoes. Mirrors toil-backend's connect_src.ts;
3
+ // other path; a "/greet" path stages an egress frame DURING @connect (the host returns it as initial
4
+ // egress and drains the ring so it does not contaminate the first @message reply). A @message echoes. Mirrors toil-backend's connect_src.ts;
5
5
  // exercises the whole @connect bridge (stream_info block -> StreamInbound.path() -> accept/reject).
6
6
 
7
7
  @stream('gate')
8
8
  class Gate {
9
- @connect onConnect(c: StreamInbound): StreamOutbound {
10
- if (c.path() == "/blocked") return StreamOutbound.reject(0x0211);
11
- if (c.path() == "/greet") {
12
- const g = new Uint8Array(3);
13
- g[0] = 0x47; g[1] = 0x48; g[2] = 0x49; // "GHI"
14
- StreamOutbound.reply(g);
15
- return StreamOutbound.accept();
9
+ @connect onConnect(c: StreamInbound): StreamOutbound {
10
+ if (c.path() == '/blocked') return StreamOutbound.reject(0x0211);
11
+ if (c.path() == '/greet') {
12
+ const g = new Uint8Array(3);
13
+ g[0] = 0x47;
14
+ g[1] = 0x48;
15
+ g[2] = 0x49; // "GHI"
16
+ StreamOutbound.reply(g);
17
+ return StreamOutbound.accept();
18
+ }
19
+ return StreamOutbound.accept();
20
+ }
21
+ @message reply(p: StreamPacket): StreamOutbound {
22
+ return StreamOutbound.reply(p.bytes());
16
23
  }
17
- return StreamOutbound.accept();
18
- }
19
- @message reply(p: StreamPacket): StreamOutbound {
20
- return StreamOutbound.reply(p.bytes());
21
- }
22
24
  }
23
25
 
24
- export function probe(): i32 { return 1; }
26
+ export function probe(): i32 {
27
+ return 1;
28
+ }
@@ -392,4 +392,58 @@ describe('ssr build orchestration', () => {
392
392
  fs.rmSync(dir, { recursive: true, force: true });
393
393
  }
394
394
  });
395
+
396
+ it('bakes the route SEO (title/description/og incl og:image) into the template <head>', () => {
397
+ const art = extractRouteTemplate({
398
+ name: 'profile',
399
+ Page: ProfilePage,
400
+ layouts: [Layout],
401
+ loaderData: sample,
402
+ loaderContext: LoaderDataContext,
403
+ setSsrBuild: __setSsrBuild,
404
+ shell: SHELL,
405
+ seo: {
406
+ url: 'https://ex.com',
407
+ title: 'Site',
408
+ openGraph: { siteName: 'Site', image: 'https://ex.com/og.png' },
409
+ },
410
+ metadata: {
411
+ title: 'Profile',
412
+ description: 'A user profile',
413
+ openGraph: { title: 'Profile OG' },
414
+ },
415
+ pattern: '/u/ada',
416
+ });
417
+ const tmpl = art.tmpl.toString('utf8');
418
+ // The route's own title replaces the shell's, and its metadata + the site SEO are in <head>,
419
+ // so an SSR route serves the same per-page tags a crawler would get from <route>/index.html.
420
+ expect(tmpl).toContain('<title>Profile</title>');
421
+ expect(tmpl).not.toContain('<title>t</title>');
422
+ expect(tmpl).toContain('<meta name="description" content="A user profile" />');
423
+ expect(tmpl).toContain('<meta property="og:title" content="Profile OG" />');
424
+ expect(tmpl).toContain('<meta property="og:image" content="https://ex.com/og.png" />');
425
+ // Per-route canonical / og:url point at THIS route, not the site root.
426
+ expect(tmpl).toContain('<link rel="canonical" href="https://ex.com/u/ada" />');
427
+ expect(tmpl).toContain('<meta property="og:url" content="https://ex.com/u/ada" />');
428
+ // The baked head is inside art.tmpl, so it is covered by the coherence hash.
429
+ expect(art.hash).toHaveLength(32);
430
+ // Holes are unaffected (still body-only).
431
+ expect(art.slotCount).toBe(3);
432
+ });
433
+
434
+ it('skips SEO injection when no seo config is passed (head unchanged)', () => {
435
+ const art = extractRouteTemplate({
436
+ name: 'p',
437
+ Page: ProfilePage,
438
+ layouts: [Layout],
439
+ loaderData: sample,
440
+ loaderContext: LoaderDataContext,
441
+ setSsrBuild: __setSsrBuild,
442
+ shell: SHELL,
443
+ });
444
+ const tmpl = art.tmpl.toString('utf8');
445
+ expect(tmpl).toContain('<title>t</title>'); // shell title untouched
446
+ expect(tmpl).not.toContain('og:image');
447
+ expect(tmpl).not.toContain('rel="canonical"');
448
+ });
395
449
  });
@@ -30,8 +30,10 @@ import {
30
30
  import { StreamWsSession, type StreamWsTransport } from '../src/devserver/stream/ws.js';
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
- // The LOCAL toilscript build (the @message + @connect bridge codegen); the published dep predates it.
34
- const LOCAL_TOILSCRIPT_BIN = join(here, '..', '..', 'toilscript', 'bin', 'toilscript.js');
33
+ // The toilscript build with the @message + @connect bridge codegen (it ships in the dep now).
34
+ // Resolved from node_modules so this works both locally (the symlinked repo) and in CI (the npm
35
+ // dep), not from a sibling checkout that CI does not have.
36
+ const LOCAL_TOILSCRIPT_BIN = join(here, '..', 'node_modules', 'toilscript', 'bin', 'toilscript.js');
35
37
 
36
38
  let tmp: string;
37
39
  let ECHO_PATH: string;
@@ -50,7 +52,9 @@ function compile(srcName: string): { path: string; wasm: Buffer } {
50
52
  { encoding: 'utf8' },
51
53
  );
52
54
  if (r.status !== 0) {
53
- throw new Error(`toilscript compile ${srcName} failed (${String(r.status)}):\n${r.stderr}${r.stdout}`);
55
+ throw new Error(
56
+ `toilscript compile ${srcName} failed (${String(r.status)}):\n${r.stderr}${r.stdout}`,
57
+ );
54
58
  }
55
59
  return { path: out, wasm: readFileSync(out) };
56
60
  }
@@ -140,10 +144,17 @@ describe('dev stream box: the @connect info-block bridge', () => {
140
144
  it('reads the connect path and rejects /blocked while accepting others', () => {
141
145
  const box = DevStreamBox.load(GATE);
142
146
  expect(box.hasConnectBridge).toBe(true);
143
- expect(box.onConnect(id, 'acme.toil', '/blocked')).toEqual({ kind: 'reject', code: 0x0211 });
147
+ expect(box.onConnect(id, 'acme.toil', '/blocked')).toEqual({
148
+ kind: 'reject',
149
+ code: 0x0211,
150
+ initialEgress: [],
151
+ });
144
152
 
145
153
  const ok = DevStreamBox.load(GATE);
146
- expect(ok.onConnect(id, 'acme.toil', '/room/42')).toEqual({ kind: 'accept' });
154
+ expect(ok.onConnect(id, 'acme.toil', '/room/42')).toEqual({
155
+ kind: 'accept',
156
+ initialEgress: [],
157
+ });
147
158
  // An accepted connection is usable: its @message echoes.
148
159
  expect(ok.onMessage(id, Buffer.from('hi'))).toEqual({
149
160
  kind: 'reply',
@@ -151,11 +162,14 @@ describe('dev stream box: the @connect info-block bridge', () => {
151
162
  });
152
163
  });
153
164
 
154
- it('clears @connect-staged egress so the first @message reply is clean', () => {
165
+ it('returns @connect-staged egress and keeps the first @message reply clean', () => {
155
166
  const box = DevStreamBox.load(GATE);
156
- // /greet stages "GHI" during @connect; the host clears it on accept.
157
- expect(box.onConnect(id, 'acme.toil', '/greet')).toEqual({ kind: 'accept' });
158
- // The first @message must see ONLY its own reply, never the stale "GHI".
167
+ // /greet stages "GHI" during @connect; the host returns it as initial egress.
168
+ expect(box.onConnect(id, 'acme.toil', '/greet')).toEqual({
169
+ kind: 'accept',
170
+ initialEgress: [Buffer.from('GHI')],
171
+ });
172
+ // The first @message must see ONLY its own reply, never the drained initial "GHI".
159
173
  expect(box.onMessage(id, Buffer.from('hi'))).toEqual({
160
174
  kind: 'reply',
161
175
  frames: [Buffer.from('hi')],
@@ -185,6 +199,7 @@ describe('dev stream session driver (StreamDevHost)', () => {
185
199
  expect(host.acceptUpgrade('c1', 'acme.toil', '/blocked')).toEqual({
186
200
  kind: 'rejected',
187
201
  code: 0x0211,
202
+ initialEgress: [],
188
203
  });
189
204
  expect(host.activeConnections).toBe(0);
190
205
  expect(host.acceptUpgrade('c2', 'acme.toil', '/ok').kind).toBe('accepted');
@@ -211,6 +226,18 @@ describe('dev stream session driver (StreamDevHost)', () => {
211
226
  host.acceptUpgrade('c1', 'acme.toil', '/');
212
227
  expect(() => host.acceptUpgrade('c1', 'acme.toil', '/')).toThrow();
213
228
  });
229
+
230
+ it('returns initial egress from @connect while keeping dispatch clean', () => {
231
+ const host = new StreamDevHost(GATE_PATH);
232
+ expect(host.acceptUpgrade('c1', 'acme.toil', '/greet')).toMatchObject({
233
+ kind: 'accepted',
234
+ initialEgress: [Buffer.from('GHI')],
235
+ });
236
+ expect(host.dispatch('c1', Buffer.from('hi'))).toEqual({
237
+ kind: 'reply',
238
+ frames: [Buffer.from('hi')],
239
+ });
240
+ });
214
241
  });
215
242
 
216
243
  describe('dev stream WS session adapter (StreamWsSession)', () => {
@@ -237,6 +264,20 @@ describe('dev stream WS session adapter (StreamWsSession)', () => {
237
264
  expect(host.activeConnections).toBe(0);
238
265
  });
239
266
 
267
+ it('sends @connect initial egress on open', () => {
268
+ const host = new StreamDevHost(GATE_PATH);
269
+ const { sent, closed, t } = makeTransport();
270
+ const s = new StreamWsSession(host, 'ws1', 'acme.toil', '/greet', t);
271
+ expect(s.onOpen()).toBe(true);
272
+ expect(s.isOpen).toBe(true);
273
+ expect(sent).toEqual([Buffer.from('GHI')]);
274
+ expect(closed).toEqual([]);
275
+ s.onMessage(Buffer.from('hi'));
276
+ expect(sent).toEqual([Buffer.from('GHI'), Buffer.from('hi')]);
277
+ s.onClose();
278
+ expect(host.activeConnections).toBe(0);
279
+ });
280
+
240
281
  it('closes the socket with the code on a guest reject, then fires @close on socket close', () => {
241
282
  const host = new StreamDevHost(ECHO_PATH);
242
283
  const { closed, t } = makeTransport();
@@ -310,7 +351,12 @@ describe('dev stream router (doc 08 4.1/4.2)', () => {
310
351
  expect(router.matchRoute('/not-a-stream')).toBeNull(); // -> proxied to Vite
311
352
 
312
353
  const ws = new MockWs();
313
- const ctx: StreamUpgradeContext = { kind: 'stream', route, url: route, authority: 'acme.toil' };
354
+ const ctx: StreamUpgradeContext = {
355
+ kind: 'stream',
356
+ route,
357
+ url: route,
358
+ authority: 'acme.toil',
359
+ };
314
360
  router.onUpgrade(ws, ctx);
315
361
  expect(router.activeConnections).toBe(1);
316
362
  ws.emitMessage(Buffer.from('hi'));