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.
- package/CHANGELOG.md +19 -0
- package/TYPESCRIPT_LAW.md +1 -3
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Image.js +11 -14
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +9 -0
- package/build/compiler/template-build.d.ts +4 -0
- package/build/compiler/template-build.js +11 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/runtime/module.js +2 -0
- package/build/devserver/stream/index.d.ts +2 -1
- package/build/devserver/stream/index.js +3 -11
- package/build/devserver/stream/manager.d.ts +2 -0
- package/build/devserver/stream/manager.js +7 -6
- package/build/devserver/stream/ws.js +4 -0
- package/package.json +2 -9
- package/src/client/components/Image.tsx +18 -13
- package/src/compiler/generate.ts +16 -0
- package/src/compiler/template-build.ts +32 -1
- package/src/devserver/runtime/module.ts +3 -0
- package/src/devserver/stream/index.ts +8 -19
- package/src/devserver/stream/manager.ts +15 -11
- package/src/devserver/stream/ws.ts +2 -0
- package/test/assembly/aspect-shim.ts +33 -0
- package/test/assembly/cookie.spec.ts +1 -0
- package/test/assembly/example.spec.ts +1 -0
- package/test/assembly/ssr.spec.ts +1 -0
- package/test/assembly.test.ts +59 -0
- package/test/dom/Image.test.tsx +25 -2
- package/test/fixtures/stream-gate.ts +19 -15
- package/test/ssr-template.test.tsx +54 -0
- package/test/stream-emulation.test.ts +56 -10
- package/vitest.config.ts +2 -1
- package/as-pect.asconfig.json +0 -34
- package/as-pect.config.js +0 -71
package/src/compiler/generate.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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`,
|
|
136
|
-
*
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
73
|
-
* missing/unreadable artifact (`0x0208`), a
|
|
74
|
-
* trap (`0x0200`). Throws only on a
|
|
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))
|
|
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
|
+
});
|
package/test/dom/Image.test.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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
|
|
34
|
-
|
|
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(
|
|
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({
|
|
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({
|
|
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('
|
|
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
|
|
157
|
-
expect(box.onConnect(id, 'acme.toil', '/greet')).toEqual({
|
|
158
|
-
|
|
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 = {
|
|
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'));
|