toiljs 0.0.34 → 0.0.37

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 (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -0
  3. package/as-pect.config.js +8 -2
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +97 -0
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +182 -0
  9. package/build/client/index.d.ts +5 -1
  10. package/build/client/index.js +3 -1
  11. package/build/client/routing/loader.d.ts +1 -0
  12. package/build/client/routing/loader.js +37 -0
  13. package/build/client/routing/mount.js +32 -1
  14. package/build/client/ssr/markers.d.ts +34 -0
  15. package/build/client/ssr/markers.js +49 -0
  16. package/build/compiler/.tsbuildinfo +1 -1
  17. package/build/compiler/docs.js +88 -1
  18. package/build/compiler/generate.d.ts +2 -0
  19. package/build/compiler/generate.js +2 -2
  20. package/build/compiler/index.js +2 -0
  21. package/build/compiler/ssr-codegen.d.ts +2 -0
  22. package/build/compiler/ssr-codegen.js +36 -0
  23. package/build/compiler/template-build.d.ts +29 -0
  24. package/build/compiler/template-build.js +150 -0
  25. package/build/compiler/template.d.ts +22 -0
  26. package/build/compiler/template.js +169 -0
  27. package/build/devserver/.tsbuildinfo +1 -1
  28. package/build/devserver/crypto.js +15 -0
  29. package/build/devserver/host.js +1 -0
  30. package/build/devserver/module.d.ts +1 -0
  31. package/build/devserver/module.js +23 -1
  32. package/docs/README.md +56 -0
  33. package/docs/auth.md +261 -0
  34. package/docs/caching.md +115 -0
  35. package/docs/cookies.md +457 -0
  36. package/docs/crypto.md +130 -0
  37. package/docs/data.md +131 -0
  38. package/docs/getting-started.md +128 -0
  39. package/docs/routing.md +259 -0
  40. package/docs/rpc.md +149 -0
  41. package/docs/ssr.md +184 -0
  42. package/docs/time.md +43 -0
  43. package/examples/basic/client/routes/auth.tsx +198 -0
  44. package/examples/basic/client/routes/cookies.tsx +199 -0
  45. package/examples/basic/client/routes/features/index.tsx +34 -10
  46. package/examples/basic/client/routes/hello.tsx +43 -0
  47. package/examples/basic/client/routes/pq.tsx +260 -0
  48. package/examples/basic/server/AuthTestHandler.ts +15 -0
  49. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  50. package/examples/basic/server/CacheHandler.ts +25 -0
  51. package/examples/basic/server/DecoCache.ts +18 -0
  52. package/examples/basic/server/FastTrapHandler.ts +8 -0
  53. package/examples/basic/server/SpinHandler.ts +18 -0
  54. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  55. package/examples/basic/server/authexample-main.ts +8 -0
  56. package/examples/basic/server/authtest-main.ts +8 -0
  57. package/examples/basic/server/authverify-main.ts +8 -0
  58. package/examples/basic/server/cache-main.ts +8 -0
  59. package/examples/basic/server/core/AppHandler.ts +243 -0
  60. package/examples/basic/server/deco-main.ts +18 -0
  61. package/examples/basic/server/main.ts +2 -0
  62. package/examples/basic/server/routes/Auth.ts +184 -0
  63. package/examples/basic/server/routes/PqDemo.ts +130 -0
  64. package/examples/basic/server/routes/Session.ts +74 -0
  65. package/examples/basic/server/spin-main.ts +13 -0
  66. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  67. package/examples/basic/server/ssr-main.ts +18 -0
  68. package/examples/basic/server/toil-server-env.d.ts +94 -0
  69. package/examples/basic/server/trap-main.ts +8 -0
  70. package/package.json +5 -3
  71. package/server/globals/auth.ts +281 -0
  72. package/server/runtime/README.md +61 -0
  73. package/server/runtime/env/Server.ts +12 -0
  74. package/server/runtime/exports/index.ts +17 -0
  75. package/server/runtime/exports/render.ts +51 -0
  76. package/server/runtime/http/base64.ts +104 -0
  77. package/server/runtime/http/cookie.ts +416 -0
  78. package/server/runtime/http/cookies.ts +197 -0
  79. package/server/runtime/http/date.ts +72 -0
  80. package/server/runtime/http/percent.ts +76 -0
  81. package/server/runtime/http/securecookies.ts +224 -0
  82. package/server/runtime/index.ts +17 -0
  83. package/server/runtime/request.ts +24 -0
  84. package/server/runtime/response.ts +29 -0
  85. package/server/runtime/ssr/Ssr.ts +43 -0
  86. package/server/runtime/ssr/encode.ts +110 -0
  87. package/server/runtime/ssr/escape.ts +83 -0
  88. package/server/runtime/ssr/slots.ts +144 -0
  89. package/server/runtime/time.ts +29 -0
  90. package/src/cli/create.ts +105 -0
  91. package/src/client/auth.ts +327 -0
  92. package/src/client/index.ts +5 -1
  93. package/src/client/routing/loader.ts +56 -0
  94. package/src/client/routing/mount.tsx +37 -1
  95. package/src/client/ssr/markers.tsx +140 -0
  96. package/src/compiler/docs.ts +88 -1
  97. package/src/compiler/generate.ts +2 -2
  98. package/src/compiler/index.ts +5 -0
  99. package/src/compiler/ssr-codegen.ts +85 -0
  100. package/src/compiler/template-build.ts +275 -0
  101. package/src/compiler/template.ts +265 -0
  102. package/src/devserver/crypto.ts +23 -0
  103. package/src/devserver/host.ts +4 -0
  104. package/src/devserver/module.ts +39 -1
  105. package/test/assembly/cookie.spec.ts +302 -0
  106. package/test/assembly/example.spec.ts +5 -1
  107. package/test/assembly/ssr.spec.ts +94 -0
  108. package/test/devserver.test.ts +42 -0
  109. package/test/ssr-render.test.ts +128 -0
  110. package/test/ssr-template.test.tsx +348 -0
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Client half of the post-quantum auth primitive (browser).
3
+ *
4
+ * The password never leaves this module. It is stretched with Argon2id into a
5
+ * 32-byte seed, which deterministically expands into an ML-DSA-44 keypair
6
+ * (FIPS 204). Only the 1312-byte PUBLIC key is ever sent; the secret key and
7
+ * the seed are zeroized the instant signing is done. There is no recovery: the
8
+ * password IS the key.
9
+ *
10
+ * Wire encoding is deterministic binary (`DataWriter`/`DataReader`), never
11
+ * JSON, byte-identical to the server's `AuthService.buildLoginMessage`.
12
+ */
13
+
14
+ import { argon2id, sha256 } from 'hash-wasm';
15
+ import { ml_dsa44 } from '@btc-vision/post-quantum/ml-dsa.js';
16
+
17
+ import { DataReader, DataWriter } from 'toiljs/io';
18
+
19
+ /** FIPS 204 signing context (domain separator). Byte-identical to the server. */
20
+ export const LOGIN_CONTEXT = 'qauth:login:v1';
21
+
22
+ export const PUBLIC_KEY_LEN = 1312;
23
+ export const SECRET_KEY_LEN = 2560;
24
+ export const SIGNATURE_LEN = 2420;
25
+ export const SEED_LEN = 32;
26
+
27
+ /** Argon2id parameters, pinned PER ACCOUNT and echoed by the server. The client
28
+ * derives against whatever it is handed -- never hardcoded -- so a future
29
+ * parameter bump just works. They are part of the credential. */
30
+ export interface KdfParams {
31
+ /** `m`: memory in kibibytes (>= 256 MiB = 262144). */
32
+ readonly memKiB: number;
33
+ /** `t`: iterations (>= 3). */
34
+ readonly iterations: number;
35
+ /** `p`: degree of parallelism. */
36
+ readonly parallelism: number;
37
+ /** per-account salt (16 bytes, server-issued). */
38
+ readonly salt: Uint8Array;
39
+ }
40
+
41
+ /** A server login challenge. */
42
+ export interface Challenge {
43
+ readonly cid: Uint8Array;
44
+ readonly aud: string;
45
+ readonly kdf: KdfParams;
46
+ readonly nonce: Uint8Array;
47
+ readonly iat: bigint;
48
+ readonly exp: bigint;
49
+ }
50
+
51
+ /** Overwrite a secret buffer with random bytes, then zero. Best-effort: JS GC
52
+ * cannot scrub copies, so we never store or close over secrets beyond one call. */
53
+ function wipe(buf: Uint8Array): void {
54
+ crypto.getRandomValues(buf as unknown as Uint8Array<ArrayBuffer>);
55
+ buf.fill(0);
56
+ }
57
+
58
+ /** Argon2id(NFKC(password), salt; m,t,p, len=32) -> 32-byte ML-DSA seed. */
59
+ async function deriveSeed(password: string, kdf: KdfParams): Promise<Uint8Array> {
60
+ return argon2id({
61
+ password: new TextEncoder().encode(password.normalize('NFKC')),
62
+ salt: kdf.salt,
63
+ iterations: kdf.iterations,
64
+ parallelism: kdf.parallelism,
65
+ memorySize: kdf.memKiB,
66
+ hashLength: SEED_LEN,
67
+ outputType: 'binary',
68
+ });
69
+ }
70
+
71
+ /** The canonical login message `M`, fixed binary layout (see the server's
72
+ * `AuthService.buildLoginMessage`). Both ends MUST produce identical bytes. */
73
+ export function buildLoginMessage(
74
+ sub: string,
75
+ aud: string,
76
+ cid: Uint8Array,
77
+ nonce: Uint8Array,
78
+ iat: bigint,
79
+ exp: bigint,
80
+ ): Uint8Array {
81
+ return new DataWriter()
82
+ .writeU8(1)
83
+ .writeString(sub)
84
+ .writeString(aud)
85
+ .writeBytes(cid)
86
+ .writeBytes(nonce)
87
+ .writeU64(iat)
88
+ .writeU64(exp)
89
+ .toBytes();
90
+ }
91
+
92
+ // ---- wire codecs (the example `Auth` @rest controller mirrors these) -------
93
+
94
+ function decodeKdf(r: DataReader): KdfParams {
95
+ return {
96
+ memKiB: r.readU32(),
97
+ iterations: r.readU32(),
98
+ parallelism: r.readU32(),
99
+ salt: r.readBytes(),
100
+ };
101
+ }
102
+
103
+ function decodeChallenge(r: DataReader): Challenge {
104
+ const cid = r.readBytes();
105
+ const aud = r.readString();
106
+ const kdf = decodeKdf(r);
107
+ const nonce = r.readBytes();
108
+ const iat = r.readU64();
109
+ const exp = r.readU64();
110
+ return { cid, aud, kdf, nonce, iat, exp };
111
+ }
112
+
113
+ async function postBinary(baseUrl: string, path: string, body: Uint8Array): Promise<DataReader> {
114
+ const res = await fetch(baseUrl + path, {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/octet-stream' },
117
+ body: body as BodyInit,
118
+ credentials: 'same-origin',
119
+ });
120
+ if (!res.ok) throw new Error('auth: request failed');
121
+ return new DataReader(new Uint8Array(await res.arrayBuffer()));
122
+ }
123
+
124
+ export interface AuthOptions {
125
+ /** Endpoint prefix the server mounts the auth controller under. */
126
+ readonly baseUrl?: string;
127
+ }
128
+
129
+ /**
130
+ * Register a new account: the server issues a salt + KDF params, the client
131
+ * derives the keypair and submits ONLY the public key. Throws on failure.
132
+ */
133
+ export async function register(username: string, password: string, opts: AuthOptions = {}): Promise<void> {
134
+ const baseUrl = opts.baseUrl ?? '/auth';
135
+
136
+ // 1. Ask the server for a salt + params (it also confirms the name is free).
137
+ const start = await postBinary(baseUrl, '/register/start', new DataWriter().writeString(username).toBytes());
138
+ const status = start.readU8();
139
+ if (status !== 0) throw new Error('auth: registration unavailable');
140
+ const kdf = decodeKdf(start);
141
+
142
+ // 2. Derive, keep only the public key, wipe the secret + seed immediately.
143
+ const seed = await deriveSeed(password, kdf);
144
+ let publicKey: Uint8Array;
145
+ try {
146
+ const kp = ml_dsa44.keygen(seed);
147
+ publicKey = kp.publicKey;
148
+ wipe(kp.secretKey);
149
+ } finally {
150
+ wipe(seed);
151
+ }
152
+ if (publicKey.length !== PUBLIC_KEY_LEN) throw new Error('auth: bad public key length');
153
+
154
+ // 3. Submit the public key.
155
+ const finish = await postBinary(
156
+ baseUrl,
157
+ '/register/finish',
158
+ new DataWriter().writeString(username).writeBytes(publicKey).toBytes(),
159
+ );
160
+ if (finish.readU8() !== 0) throw new Error('auth: registration rejected');
161
+ }
162
+
163
+ /**
164
+ * Log in: fetch a challenge, re-derive the keypair, sign the rebuilt message
165
+ * under the login context, and submit only `{cid, signature}`. The secret key
166
+ * and seed are wiped the instant the single sign completes. Returns the opaque
167
+ * session token the server mints (and any session cookie it sets). Throws on
168
+ * failure with one generic message.
169
+ */
170
+ export async function login(username: string, password: string, opts: AuthOptions = {}): Promise<Uint8Array> {
171
+ const baseUrl = opts.baseUrl ?? '/auth';
172
+
173
+ // 1. Challenge (the server returns one even for unknown users -> no oracle).
174
+ const ch = decodeChallenge(
175
+ await postBinary(baseUrl, '/login/start', new DataWriter().writeString(username).toBytes()),
176
+ );
177
+
178
+ // Client-side fast-fail only; the server re-checks expiry authoritatively.
179
+ if (BigInt(Math.floor(Date.now() / 1000)) >= ch.exp) throw new Error('auth: challenge expired');
180
+
181
+ // 2. Build the exact message, derive, sign once, wipe.
182
+ const message = buildLoginMessage(username, ch.aud, ch.cid, ch.nonce, ch.iat, ch.exp);
183
+ const seed = await deriveSeed(password, ch.kdf);
184
+ let signature: Uint8Array;
185
+ try {
186
+ const kp = ml_dsa44.keygen(seed);
187
+ try {
188
+ signature = ml_dsa44.sign(message, kp.secretKey, { context: new TextEncoder().encode(LOGIN_CONTEXT) });
189
+ } finally {
190
+ wipe(kp.secretKey);
191
+ }
192
+ } finally {
193
+ wipe(seed);
194
+ }
195
+ if (signature.length !== SIGNATURE_LEN) throw new Error('auth: bad signature length');
196
+
197
+ // 3. Submit {cid, signature}; the server consumes the challenge atomically,
198
+ // rebuilds the message from its own stored values, and verifies.
199
+ const res = await postBinary(
200
+ baseUrl,
201
+ '/login/finish',
202
+ new DataWriter().writeBytes(ch.cid).writeBytes(signature).toBytes(),
203
+ );
204
+ if (res.readU8() !== 0) throw new Error('auth: login failed');
205
+ return res.readBytes(); // session token
206
+ }
207
+
208
+ /** Lowercase hex of `bytes`. */
209
+ function toHex(bytes: Uint8Array): string {
210
+ let s = '';
211
+ for (const b of bytes) s += b.toString(16).padStart(2, '0');
212
+ return s;
213
+ }
214
+
215
+ /** The signed identity proof produced by {@link proveIdentity}. */
216
+ export interface IdentityProof {
217
+ /** The wire envelope to POST to `/pq/verify`: `str(sub) str(token)
218
+ * bytes(publicKey) bytes(signature)`, where `token` is the edge's
219
+ * HMAC-signed challenge. The server re-opens the token, rebuilds the login
220
+ * message from the values inside it, and `AuthService.verifyLogin`s it. */
221
+ readonly envelope: Uint8Array;
222
+ /** First bytes of the 1312-byte ML-DSA-44 public key, for display. */
223
+ readonly publicKeyHex: string;
224
+ /** First bytes of the SERVER-issued nonce that was signed, for display. */
225
+ readonly nonceHex: string;
226
+ /** Signature length (always 2420 for ML-DSA-44), for display. */
227
+ readonly signatureLen: number;
228
+ /** Argon2id wall-clock spent deriving the keypair, ms (for display). */
229
+ readonly deriveMs: number;
230
+ }
231
+
232
+ /** Deterministic 16-byte Argon2id salt for the demo, so the same
233
+ * username + password always maps to the same identity (keypair).
234
+ * Uses hash-wasm's SHA-256 (pure WebAssembly), not `crypto.subtle`, so it
235
+ * works in an insecure context (plain HTTP), where `crypto.subtle` is
236
+ * undefined. */
237
+ async function demoSalt(username: string): Promise<Uint8Array> {
238
+ const hex = await sha256('pq-demo|' + username);
239
+ const out = new Uint8Array(16);
240
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * DEMO helper: run the full post-quantum challenge-response in the browser.
246
+ * Fetches a SERVER-issued challenge (`GET {baseUrl}/challenge`), stretches the
247
+ * password with Argon2id into an ML-DSA-44 keypair, signs the login message
248
+ * built from the SERVER's nonce/cid/iat/exp, and returns the wire envelope the
249
+ * edge verifies (`AuthService.verifyLogin`). The secret key and seed are wiped
250
+ * before returning; only the public key + signature leave the tab.
251
+ *
252
+ * The nonce is server-chosen and tamper-proof (the challenge token is
253
+ * HMAC-signed by the edge), so a client cannot pre-sign or substitute its own.
254
+ * It is still NOT the full production login -- there is no single-use consume, so
255
+ * within the challenge TTL a captured proof could be replayed; that needs an
256
+ * atomic store (see {@link login} and server/routes/Auth.ts). Demo-light
257
+ * Argon2id params (16 MiB / 2 passes) keep it responsive in a tab; a real
258
+ * deployment uses >= 256 MiB.
259
+ */
260
+ export async function proveIdentity(
261
+ username: string,
262
+ password: string,
263
+ opts: { baseUrl?: string } = {},
264
+ ): Promise<IdentityProof> {
265
+ const baseUrl = opts.baseUrl ?? '/pq';
266
+
267
+ // 1. Server-issued challenge: aud, cid, nonce, iat, exp, and the signed token.
268
+ const cres = await fetch(baseUrl + '/challenge', { credentials: 'same-origin' });
269
+ if (!cres.ok) throw new Error('pq: challenge request failed');
270
+ const cr = new DataReader(new Uint8Array(await cres.arrayBuffer()));
271
+ const aud = cr.readString();
272
+ const cid = cr.readBytes();
273
+ const nonce = cr.readBytes();
274
+ const iat = cr.readU64();
275
+ const exp = cr.readU64();
276
+ const token = cr.readString();
277
+
278
+ // 2. Derive the keypair and sign the message built from the SERVER's values.
279
+ const salt = await demoSalt(username);
280
+ const t0 = Date.now();
281
+ const seed = await argon2id({
282
+ password: new TextEncoder().encode(password.normalize('NFKC')),
283
+ salt,
284
+ iterations: 2,
285
+ parallelism: 1,
286
+ memorySize: 16 * 1024, // 16 MiB: demo-light, responsive in a tab
287
+ hashLength: SEED_LEN,
288
+ outputType: 'binary',
289
+ });
290
+ const deriveMs = Date.now() - t0;
291
+
292
+ const message = buildLoginMessage(username, aud, cid, nonce, iat, exp);
293
+ let publicKey: Uint8Array;
294
+ let signature: Uint8Array;
295
+ try {
296
+ const kp = ml_dsa44.keygen(seed);
297
+ publicKey = kp.publicKey;
298
+ try {
299
+ signature = ml_dsa44.sign(message, kp.secretKey, {
300
+ context: new TextEncoder().encode(LOGIN_CONTEXT),
301
+ });
302
+ } finally {
303
+ wipe(kp.secretKey);
304
+ }
305
+ } finally {
306
+ wipe(seed);
307
+ }
308
+
309
+ // 3. Envelope: sub + the server's token + the public key + the signature.
310
+ const envelope = new DataWriter()
311
+ .writeString(username)
312
+ .writeString(token)
313
+ .writeBytes(publicKey)
314
+ .writeBytes(signature)
315
+ .toBytes();
316
+
317
+ return {
318
+ envelope,
319
+ publicKeyHex: toHex(publicKey.slice(0, 16)),
320
+ nonceHex: toHex(nonce.slice(0, 16)),
321
+ signatureLen: signature.length,
322
+ deriveMs,
323
+ };
324
+ }
325
+
326
+ /** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
327
+ export const Auth = { register, login, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } as const;
@@ -10,6 +10,8 @@
10
10
 
11
11
  export { mount } from './routing/mount.js';
12
12
  export { Router } from './routing/Router.js';
13
+ export { Auth, register as authRegister, login as authLogin, proveIdentity, buildLoginMessage, LOGIN_CONTEXT } from './auth.js';
14
+ export type { KdfParams, Challenge, AuthOptions, IdentityProof } from './auth.js';
13
15
  export { Link } from './navigation/Link.js';
14
16
  export type { LinkProps } from './navigation/Link.js';
15
17
  export { NavLink, matchActive } from './navigation/NavLink.js';
@@ -34,7 +36,7 @@ export {
34
36
  useNavigationPending,
35
37
  } from './routing/hooks.js';
36
38
  export type { RouterInstance } from './routing/hooks.js';
37
- export { useLoaderData, revalidate, invalidateLoaderData } from './routing/loader.js';
39
+ export { useLoaderData, revalidate, invalidateLoaderData, LoaderDataContext } from './routing/loader.js';
38
40
  export type {
39
41
  LoaderArgs,
40
42
  LoaderFunction,
@@ -89,3 +91,5 @@ export { Slot } from './components/Slot.js';
89
91
  export type { SlotProps } from './components/Slot.js';
90
92
  export { Server } from './rpc.js';
91
93
  export { parseError } from './errors.js';
94
+ export { Hole, RawHtml, Repeat, Island, __setSsrBuild, __isSsrBuild } from './ssr/markers.js';
95
+ export type { HoleProps, RawHtmlProps, RepeatProps, IslandProps } from './ssr/markers.js';
@@ -260,6 +260,62 @@ export function readRouteData(
260
260
  return entry.value;
261
261
  }
262
262
 
263
+ /**
264
+ * Seed the loader cache from server-rendered hydration state, BEFORE the first
265
+ * client render (called by `mount` on an SSR document). The route's module
266
+ * still loads (the browser needs the component), but its `loader` does NOT
267
+ * re-run: the server's `data` is used, so the first client render reproduces
268
+ * the server HTML and `hydrateRoot` stays clean. The entry is `prefetched`, so
269
+ * the NEXT navigation re-evaluates the route's `revalidate` policy normally.
270
+ *
271
+ * For a flash-free hydrate the SSR document should modulepreload the route
272
+ * chunk, so `route.load()` resolves in a microtask and the root never suspends.
273
+ */
274
+ export function hydrateLoaderData(
275
+ route: RouteDef,
276
+ params: RouteParams,
277
+ pathname: string,
278
+ search: string,
279
+ data: unknown,
280
+ ): void {
281
+ const key = loaderKey(pathname, search);
282
+ if (cache.has(key)) return;
283
+ const epoch = navigationEpoch();
284
+ const entry: Entry = {
285
+ status: 'pending',
286
+ promise: Promise.resolve(),
287
+ loadedAt: 0,
288
+ revalidate: 0,
289
+ epoch,
290
+ hasLoader: true,
291
+ prefetched: true,
292
+ };
293
+ entry.promise = route.load().then(
294
+ async (mod: RouteModule) => {
295
+ const searchParams = new URLSearchParams(search);
296
+ let head: HeadSpec | undefined;
297
+ if (mod.generateMetadata) {
298
+ head = resolveMetadata(await mod.generateMetadata({ params, searchParams, data }));
299
+ } else if (mod.metadata) {
300
+ head = resolveMetadata(mod.metadata);
301
+ }
302
+ entry.value = { Component: mod.default, data, head };
303
+ entry.revalidate = mod.revalidate ?? 0;
304
+ entry.loadedAt = Date.now();
305
+ entry.status = 'done';
306
+ emitCache();
307
+ },
308
+ (error: unknown) => {
309
+ entry.error = error;
310
+ entry.loadedAt = Date.now();
311
+ entry.status = 'error';
312
+ emitCache();
313
+ },
314
+ );
315
+ cache.set(key, entry);
316
+ emitCache();
317
+ }
318
+
263
319
  /** Clears all cached loader data, so the next render re-runs loaders (used by router.refresh). */
264
320
  export function clearLoaderData(): void {
265
321
  cache.clear();
@@ -1,4 +1,4 @@
1
- import { createRoot } from 'react-dom/client';
1
+ import { createRoot, hydrateRoot } from 'react-dom/client';
2
2
 
3
3
  import { DevToolbar } from '../dev/devtools.js';
4
4
  import {
@@ -8,9 +8,39 @@ import {
8
8
  } from '../dev/error-overlay.js';
9
9
  import { initNavigation } from '../navigation/navigation.js';
10
10
  import { startPrefetcher } from '../navigation/prefetch.js';
11
+ import { hydrateLoaderData } from './loader.js';
12
+ import { matchRoute } from './match.js';
11
13
  import { Router } from './Router.js';
12
14
  import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
13
15
 
16
+ /** An edge-SSR document carries a `<* id="__toil_ssr">` marker baked into the
17
+ * template; its presence flips `mount` to `hydrateRoot`. */
18
+ function isSsrDocument(): boolean {
19
+ return typeof document !== 'undefined' && document.getElementById('__toil_ssr') !== null;
20
+ }
21
+
22
+ /** Seed the loader cache from the server's `#__toil_state` JSON so the first
23
+ * client render uses the same data the server stamped (clean hydration). */
24
+ function seedSsrHydration(routes: RouteDef[]): void {
25
+ if (typeof document === 'undefined' || typeof window === 'undefined') return;
26
+ const el = document.getElementById('__toil_state');
27
+ if (!el || !el.textContent) return;
28
+ let state: { data?: unknown };
29
+ try {
30
+ state = JSON.parse(el.textContent) as { data?: unknown };
31
+ } catch {
32
+ return;
33
+ }
34
+ const { pathname, search } = window.location;
35
+ for (const route of routes) {
36
+ const params = matchRoute(route.pattern, pathname);
37
+ if (params) {
38
+ hydrateLoaderData(route, params, pathname, search, state.data);
39
+ return;
40
+ }
41
+ }
42
+ }
43
+
14
44
  /**
15
45
  * Mounts the toil client app into `#root` and starts idle link prefetching. Called by the
16
46
  * compiler-generated `.toil/entry.tsx`.
@@ -46,6 +76,12 @@ export function mount(
46
76
  <DevToolbar routes={routes} slots={slots} />
47
77
  </>,
48
78
  );
79
+ } else if (isSsrDocument()) {
80
+ // Edge-SSR: the document already holds server-rendered markup. Seed the
81
+ // loader cache from `#__toil_state` and hydrate in place (reuse the DOM)
82
+ // rather than client-rendering from scratch.
83
+ seedSsrHydration(routes);
84
+ hydrateRoot(el, app);
49
85
  } else {
50
86
  createRoot(el).render(app);
51
87
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Edge-SSR hole markers. A route opts into server rendering by wrapping its
3
+ * dynamic bits in these components; the compiler's template extractor finds
4
+ * them deterministically (rather than guessing which `{expr}` is dynamic).
5
+ *
6
+ * The markers are TRANSPARENT at runtime: in the browser `<Hole>` renders its
7
+ * children, `<Repeat>` renders `each.map(...)`, `<RawHtml>` renders a
8
+ * `dangerouslySetInnerHTML` wrapper, `<Island>` renders its children. So the
9
+ * client's React tree is exactly the normal app.
10
+ *
11
+ * Under the BUILD extractor (which calls {@link __setSsrBuild}(true) before
12
+ * `renderToStaticMarkup`), each marker instead emits a unique PUA sentinel
13
+ * token marking an insertion point. The extractor strips the tokens, records
14
+ * their byte offsets, and emits the `.tmpl` + `.slots`. PUA codepoints
15
+ * (U+E000..) never occur in real HTML and React serialises them verbatim, so
16
+ * the tokens are collision-proof and strip to zero bytes.
17
+ *
18
+ * Because the static scaffold around every hole is React's OWN
19
+ * `renderToStaticMarkup` output, the browser's `hydrateRoot` sees byte-
20
+ * identical markup as long as the guest escapes hole values the same way React
21
+ * does (it does: see `server/runtime/ssr/escape.ts`).
22
+ */
23
+
24
+ import { createElement, Fragment, type ReactNode } from 'react';
25
+
26
+ /** Token framing codepoints (Unicode Private Use Area). */
27
+ export const SENTINEL_START = String.fromCharCode(0xe000);
28
+ export const SENTINEL_SEP = String.fromCharCode(0xe001);
29
+ export const SENTINEL_END = String.fromCharCode(0xe002);
30
+
31
+ /** Kind chars embedded in a sentinel token. Lowercase letters; `R`/`r` open and
32
+ * close a repeat region. */
33
+ export const enum HoleKindChar {
34
+ Text = 't',
35
+ Raw = 'h',
36
+ Attr = 'a',
37
+ RepeatOpen = 'R',
38
+ RepeatClose = 'r',
39
+ }
40
+
41
+ let ssrBuild = false;
42
+
43
+ /** Build-extractor switch. Flipped on around a `renderToStaticMarkup` pass so
44
+ * the markers emit sentinels; left `false` in the browser bundle so they are
45
+ * transparent. */
46
+ export function __setSsrBuild(on: boolean): void {
47
+ ssrBuild = on;
48
+ }
49
+
50
+ /** `true` while the extractor is rendering (test/diagnostic use). */
51
+ export function __isSsrBuild(): boolean {
52
+ return ssrBuild;
53
+ }
54
+
55
+ /** A self-closing insertion-point token, e.g. `␀t<id>␂`. */
56
+ function token(kind: HoleKindChar, id: string): string {
57
+ return SENTINEL_START + kind + id + SENTINEL_END;
58
+ }
59
+
60
+ /** Wrap a string so a component always returns a `ReactElement` (a Fragment
61
+ * with one text child renders the string verbatim). */
62
+ function textNode(s: string): ReactNode {
63
+ return createElement(Fragment, null, s);
64
+ }
65
+
66
+ export interface HoleProps {
67
+ /** Stable hole name; the extractor maps it to a numeric slot id. */
68
+ id: string;
69
+ children?: ReactNode;
70
+ }
71
+
72
+ /** A scalar text hole. Client: renders `children`. Build: a text insertion
73
+ * point the guest fills with the React-escaped value. */
74
+ export function Hole(props: HoleProps): ReactNode {
75
+ if (ssrBuild) return textNode(token(HoleKindChar.Text, props.id));
76
+ return createElement(Fragment, null, props.children);
77
+ }
78
+
79
+ export interface RawHtmlProps {
80
+ id: string;
81
+ /** Raw HTML string. The author owns sanitisation (same as React
82
+ * `dangerouslySetInnerHTML`). */
83
+ html: string;
84
+ /** Wrapper element tag (raw HTML needs a host element to live in so server
85
+ * and client DOM match). Defaults to `div`. */
86
+ as?: keyof React.JSX.IntrinsicElements;
87
+ }
88
+
89
+ /** A raw-HTML block hole. Client: `<as dangerouslySetInnerHTML>`. Build:
90
+ * `<as>SENTINEL</as>` so the `.tmpl` carries the wrapper and the guest fills
91
+ * its inner HTML. */
92
+ export function RawHtml(props: RawHtmlProps): ReactNode {
93
+ const tag = props.as ?? 'div';
94
+ if (ssrBuild) {
95
+ return createElement(tag, null, token(HoleKindChar.Raw, props.id));
96
+ }
97
+ return createElement(tag, { dangerouslySetInnerHTML: { __html: props.html } });
98
+ }
99
+
100
+ export interface RepeatProps<T> {
101
+ id: string;
102
+ /** The data rows. The build extractor requires a representative sample with
103
+ * at least one row to capture the row sub-template. */
104
+ each: readonly T[];
105
+ children: (item: T, index: number) => ReactNode;
106
+ }
107
+
108
+ /** A repeat region. Client: `each.map(children)`. Build: a region wrapping
109
+ * exactly ONE representative row (so the extractor captures the row sub-
110
+ * template + its nested holes); the host inserts the guest's pre-stamped,
111
+ * concatenated rows at the region offset. */
112
+ export function Repeat<T>(props: RepeatProps<T>): ReactNode {
113
+ if (ssrBuild) {
114
+ const sample = props.each.length > 0 ? props.each[0] : undefined;
115
+ return createElement(
116
+ Fragment,
117
+ null,
118
+ token(HoleKindChar.RepeatOpen, props.id),
119
+ sample !== undefined ? props.children(sample, 0) : null,
120
+ token(HoleKindChar.RepeatClose, props.id),
121
+ );
122
+ }
123
+ return createElement(
124
+ Fragment,
125
+ null,
126
+ props.each.map((item, i) => props.children(item, i)),
127
+ );
128
+ }
129
+
130
+ export interface IslandProps {
131
+ children?: ReactNode;
132
+ }
133
+
134
+ /** A client-only escape hatch for content outside the server-template subset.
135
+ * Client: renders `children`. Build: renders nothing (the block is empty in the
136
+ * server HTML and appears after hydration; it gets no first-paint/SEO). */
137
+ export function Island(props: IslandProps): ReactNode {
138
+ if (ssrBuild) return null;
139
+ return createElement(Fragment, null, props.children);
140
+ }