toiljs 0.0.33 → 0.0.36

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 (130) hide show
  1. package/CHANGELOG.md +19 -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 +124 -7
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +179 -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/cache.d.ts +8 -0
  29. package/build/devserver/cache.js +0 -0
  30. package/build/devserver/crypto.js +15 -0
  31. package/build/devserver/host.js +1 -0
  32. package/build/devserver/index.js +10 -1
  33. package/build/devserver/module.d.ts +1 -0
  34. package/build/devserver/module.js +23 -1
  35. package/docs/README.md +56 -0
  36. package/docs/auth.md +261 -0
  37. package/docs/caching.md +115 -0
  38. package/docs/cookies.md +457 -0
  39. package/docs/crypto.md +130 -0
  40. package/docs/data.md +131 -0
  41. package/docs/getting-started.md +128 -0
  42. package/docs/routing.md +259 -0
  43. package/docs/rpc.md +149 -0
  44. package/docs/ssr.md +184 -0
  45. package/docs/time.md +43 -0
  46. package/examples/basic/client/routes/auth.tsx +198 -0
  47. package/examples/basic/client/routes/cookies.tsx +199 -0
  48. package/examples/basic/client/routes/features/index.tsx +34 -10
  49. package/examples/basic/client/routes/hello.tsx +43 -0
  50. package/examples/basic/client/routes/pq.tsx +135 -0
  51. package/examples/basic/server/AuthTestHandler.ts +15 -0
  52. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  53. package/examples/basic/server/CacheHandler.ts +25 -0
  54. package/examples/basic/server/DecoCache.ts +18 -0
  55. package/examples/basic/server/FastTrapHandler.ts +8 -0
  56. package/examples/basic/server/README.md +19 -0
  57. package/examples/basic/server/SpinHandler.ts +18 -0
  58. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  59. package/examples/basic/server/authexample-main.ts +8 -0
  60. package/examples/basic/server/authtest-main.ts +8 -0
  61. package/examples/basic/server/authverify-main.ts +8 -0
  62. package/examples/basic/server/cache-main.ts +8 -0
  63. package/examples/basic/server/core/AppHandler.ts +290 -0
  64. package/examples/basic/server/core/store.ts +31 -0
  65. package/examples/basic/server/deco-main.ts +18 -0
  66. package/examples/basic/server/main.ts +13 -2
  67. package/examples/basic/server/models/NewPlayer.ts +5 -0
  68. package/examples/basic/server/models/Player.ts +8 -0
  69. package/examples/basic/server/models/ScoreDelta.ts +5 -0
  70. package/examples/basic/server/models/Standings.ts +7 -0
  71. package/examples/basic/server/routes/Auth.ts +184 -0
  72. package/examples/basic/server/routes/Leaderboard.ts +20 -0
  73. package/examples/basic/server/routes/Players.ts +53 -0
  74. package/examples/basic/server/routes/PqDemo.ts +109 -0
  75. package/examples/basic/server/routes/Session.ts +73 -0
  76. package/examples/basic/server/scheduled/README.md +7 -0
  77. package/examples/basic/server/services/Stats.ts +11 -0
  78. package/examples/basic/server/services/remotes.ts +7 -0
  79. package/examples/basic/server/spin-main.ts +13 -0
  80. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  81. package/examples/basic/server/ssr-main.ts +18 -0
  82. package/examples/basic/server/toil-server-env.d.ts +94 -0
  83. package/examples/basic/server/trap-main.ts +8 -0
  84. package/package.json +5 -3
  85. package/server/globals/auth.ts +281 -0
  86. package/server/runtime/README.md +61 -0
  87. package/server/runtime/env/Server.ts +12 -0
  88. package/server/runtime/exports/index.ts +17 -0
  89. package/server/runtime/exports/render.ts +51 -0
  90. package/server/runtime/http/base64.ts +104 -0
  91. package/server/runtime/http/cookie.ts +416 -0
  92. package/server/runtime/http/cookies.ts +197 -0
  93. package/server/runtime/http/date.ts +72 -0
  94. package/server/runtime/http/percent.ts +76 -0
  95. package/server/runtime/http/securecookies.ts +224 -0
  96. package/server/runtime/index.ts +17 -0
  97. package/server/runtime/request.ts +24 -0
  98. package/server/runtime/response.ts +85 -0
  99. package/server/runtime/ssr/Ssr.ts +43 -0
  100. package/server/runtime/ssr/encode.ts +110 -0
  101. package/server/runtime/ssr/escape.ts +83 -0
  102. package/server/runtime/ssr/slots.ts +144 -0
  103. package/server/runtime/time.ts +29 -0
  104. package/src/cli/create.ts +159 -14
  105. package/src/client/auth.ts +322 -0
  106. package/src/client/index.ts +5 -1
  107. package/src/client/routing/loader.ts +56 -0
  108. package/src/client/routing/mount.tsx +37 -1
  109. package/src/client/ssr/markers.tsx +140 -0
  110. package/src/compiler/docs.ts +88 -1
  111. package/src/compiler/generate.ts +2 -2
  112. package/src/compiler/index.ts +5 -0
  113. package/src/compiler/ssr-codegen.ts +85 -0
  114. package/src/compiler/template-build.ts +275 -0
  115. package/src/compiler/template.ts +265 -0
  116. package/src/devserver/cache.ts +0 -0
  117. package/src/devserver/crypto.ts +23 -0
  118. package/src/devserver/host.ts +4 -0
  119. package/src/devserver/index.ts +21 -1
  120. package/src/devserver/module.ts +39 -1
  121. package/test/assembly/cookie.spec.ts +302 -0
  122. package/test/assembly/example.spec.ts +5 -1
  123. package/test/assembly/ssr.spec.ts +94 -0
  124. package/test/devserver.test.ts +48 -4
  125. package/test/fixtures/bignum-wire/spec.ts +27 -0
  126. package/test/rpc-bignum-wire.test.ts +164 -0
  127. package/test/ssr-render.test.ts +128 -0
  128. package/test/ssr-template.test.tsx +348 -0
  129. package/examples/basic/server/HelloHandler.ts +0 -42
  130. package/examples/basic/server/api.ts +0 -137
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Percent-encoding for cookie values, matching `encodeURIComponent` /
3
+ * `decodeURIComponent` semantics (the de-facto default of Node's `cookie`
4
+ * package). The unreserved set (`A-Z a-z 0-9 - _ . ! ~ * ' ( )`) is a subset of
5
+ * the RFC 6265bis `cookie-octet` grammar, so the output is always a valid
6
+ * unquoted cookie value and arbitrary UTF-8 round-trips safely.
7
+ *
8
+ * Internal to the cookie library (not a global); surfaced through
9
+ * `Cookies.encodeValue` / `Cookies.decodeValue`.
10
+ */
11
+
12
+ const HEX: string = '0123456789ABCDEF';
13
+
14
+ function isUnreserved(c: i32): bool {
15
+ if (c >= 65 && c <= 90) return true; // A-Z
16
+ if (c >= 97 && c <= 122) return true; // a-z
17
+ if (c >= 48 && c <= 57) return true; // 0-9
18
+ // - _ . ! ~ * ' ( )
19
+ return (
20
+ c == 45 || c == 95 || c == 46 || c == 33 || c == 126 ||
21
+ c == 42 || c == 39 || c == 40 || c == 41
22
+ );
23
+ }
24
+
25
+ function hexVal(c: i32): i32 {
26
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
27
+ if (c >= 65 && c <= 70) return c - 55; // A-F
28
+ if (c >= 97 && c <= 102) return c - 87; // a-f
29
+ return -1;
30
+ }
31
+
32
+ /** Percent-encode `s` (UTF-8) into a cookie-safe value. */
33
+ export function percentEncode(s: string): string {
34
+ const bytes = Uint8Array.wrap(String.UTF8.encode(s));
35
+ let out = '';
36
+ for (let i = 0; i < bytes.length; i++) {
37
+ const c = <i32>bytes[i];
38
+ if (isUnreserved(c)) {
39
+ out += String.fromCharCode(c);
40
+ } else {
41
+ out += '%';
42
+ out += HEX.charAt(c >> 4);
43
+ out += HEX.charAt(c & 15);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Reverse {@link percentEncode}. A `%` not followed by two hex digits is kept
51
+ * literally (lenient, never throws). `+` is preserved as-is (cookies are not
52
+ * form-encoded, so `+` is not a space).
53
+ */
54
+ export function percentDecode(s: string): string {
55
+ const n = s.length;
56
+ const bytes = new Array<u8>();
57
+ let i = 0;
58
+ while (i < n) {
59
+ const c = s.charCodeAt(i);
60
+ if (c == 37 && i + 2 < n) {
61
+ // '%'
62
+ const hi = hexVal(s.charCodeAt(i + 1));
63
+ const lo = hexVal(s.charCodeAt(i + 2));
64
+ if (hi >= 0 && lo >= 0) {
65
+ bytes.push(<u8>((hi << 4) | lo));
66
+ i += 3;
67
+ continue;
68
+ }
69
+ }
70
+ bytes.push(<u8>(c & 0xff));
71
+ i++;
72
+ }
73
+ const arr = new Uint8Array(bytes.length);
74
+ for (let j = 0; j < bytes.length; j++) arr[j] = bytes[j];
75
+ return String.UTF8.decodeUnsafe(arr.dataStart, arr.byteLength);
76
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `SecureCookies` — tamper-proof and confidential cookie values, built on the
3
+ * ambient `crypto` global (no new host functions).
4
+ *
5
+ * - `SecureCookies.signed(key)` — HMAC-SHA256. The value stays readable but is
6
+ * bound to the cookie name, so it cannot be tampered with or moved to another
7
+ * cookie. Sealed form: `base64url(value) "." base64url(mac)`.
8
+ * - `SecureCookies.encrypted(key)` — AES-256-GCM with a random 96-bit IV and
9
+ * the cookie name as AAD. The value is confidential and authenticated.
10
+ * Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.
11
+ *
12
+ * Keys are caller-supplied raw bytes (HMAC: any length; AES: 16 or 32 bytes).
13
+ * Extra keys can be added for rotation: seal with the first, open with any.
14
+ *
15
+ * Verification and decryption are panic-free against attacker input: given a
16
+ * valid key, a tampered or truncated sealed value yields `null`, never a trap
17
+ * (`decrypt` reads the host return code directly rather than letting
18
+ * `subtle.decrypt` throw on a bad tag, since toilscript runs with exceptions
19
+ * disabled). Sealing with a misconfigured key (e.g. a wrong-length AES key) is a
20
+ * server-side error and is rejected up front by the factory.
21
+ *
22
+ * Ambient global (`@global`) and exported from `toiljs/server/runtime`.
23
+ */
24
+
25
+ import {
26
+ CryptoKey,
27
+ AlgorithmParams,
28
+ AesGcmParams,
29
+ HmacImportParams,
30
+ HmacParams,
31
+ ALG_AES_GCM,
32
+ ALG_SHA_256,
33
+ USAGE_SIGN,
34
+ USAGE_VERIFY,
35
+ USAGE_ENCRYPT,
36
+ USAGE_DECRYPT,
37
+ } from 'crypto';
38
+ import { DataWriter } from 'data';
39
+ import { webcrypto } from 'bindings/webcrypto';
40
+
41
+ import { Cookie, CookieEncoding } from './cookie';
42
+ import { CookieMap } from './cookies';
43
+ import { base64UrlEncode, base64UrlDecode } from './base64';
44
+
45
+ const MODE_SIGNED: i32 = 0;
46
+ const MODE_ENCRYPTED: i32 = 1;
47
+
48
+ const IV_LEN: i32 = 12;
49
+ const TAG_LEN: i32 = 16;
50
+
51
+ /** Import params carrying just the AES-GCM algorithm id (the host stores the raw key). */
52
+ class AesKeyParams extends AlgorithmParams {
53
+ serialize(w: DataWriter): void {
54
+ w.writeI32(ALG_AES_GCM);
55
+ w.writeI32(0);
56
+ }
57
+ }
58
+
59
+ function utf8(s: string): Uint8Array {
60
+ return Uint8Array.wrap(String.UTF8.encode(s));
61
+ }
62
+
63
+ function fromUtf8(b: Uint8Array): string {
64
+ return String.UTF8.decodeUnsafe(b.dataStart, b.byteLength);
65
+ }
66
+
67
+ /** AES-GCM keys must be 16 or 32 bytes; fail early with a clear message. */
68
+ function assertAesKeyLen(key: Uint8Array): void {
69
+ if (key.length != 16 && key.length != 32) {
70
+ throw new Error('SecureCookies.encrypted requires a 16- or 32-byte key (AES-128/256)');
71
+ }
72
+ }
73
+
74
+ @global
75
+ export class SecureCookies {
76
+ private mode: i32;
77
+ private keys: Array<Uint8Array>;
78
+
79
+ private constructor(mode: i32, key: Uint8Array) {
80
+ this.mode = mode;
81
+ this.keys = new Array<Uint8Array>();
82
+ this.keys.push(key);
83
+ }
84
+
85
+ /** HMAC-SHA256 signer/verifier with `key` (any length). */
86
+ static signed(key: Uint8Array): SecureCookies {
87
+ return new SecureCookies(MODE_SIGNED, key);
88
+ }
89
+
90
+ /** AES-256-GCM (or AES-128-GCM) with `key` (32 or 16 bytes). */
91
+ static encrypted(key: Uint8Array): SecureCookies {
92
+ assertAesKeyLen(key);
93
+ return new SecureCookies(MODE_ENCRYPTED, key);
94
+ }
95
+
96
+ /** Add a fallback key for rotation: sealing uses the first key, opening tries all. */
97
+ addKey(key: Uint8Array): SecureCookies {
98
+ if (this.mode == MODE_ENCRYPTED) assertAesKeyLen(key);
99
+ this.keys.push(key);
100
+ return this;
101
+ }
102
+
103
+ // --- key import (fresh per op: handles are per-request in the host) -----
104
+
105
+ private importHmac(key: Uint8Array): CryptoKey {
106
+ return crypto.subtle.importKey(
107
+ 'raw',
108
+ key,
109
+ new HmacImportParams(ALG_SHA_256),
110
+ false,
111
+ USAGE_SIGN | USAGE_VERIFY,
112
+ );
113
+ }
114
+
115
+ private importAes(key: Uint8Array): CryptoKey {
116
+ return crypto.subtle.importKey(
117
+ 'raw',
118
+ key,
119
+ new AesKeyParams(),
120
+ false,
121
+ USAGE_ENCRYPT | USAGE_DECRYPT,
122
+ );
123
+ }
124
+
125
+ // --- signing ------------------------------------------------------------
126
+
127
+ /** Return the signed (name-bound) sealed value for `name=value`. */
128
+ sign(name: string, value: string): string {
129
+ const k = this.importHmac(this.keys[0]);
130
+ const mac = crypto.subtle.sign(new HmacParams(), k, utf8(name + '=' + value));
131
+ return base64UrlEncode(utf8(value)) + '.' + base64UrlEncode(mac);
132
+ }
133
+
134
+ /** Verify a signed value for `name`, returning the plaintext or `null`. */
135
+ unsign(name: string, sealed: string): string | null {
136
+ const dot = sealed.lastIndexOf('.');
137
+ if (dot < 0) return null;
138
+
139
+ const valBytes = base64UrlDecode(sealed.substring(0, dot));
140
+ const macBytes = base64UrlDecode(sealed.substring(dot + 1));
141
+ if (valBytes == null || macBytes == null) return null;
142
+
143
+ const value = fromUtf8(valBytes);
144
+ const msg = utf8(name + '=' + value);
145
+ for (let i = 0; i < this.keys.length; i++) {
146
+ const k = this.importHmac(this.keys[i]);
147
+ // HMAC verify returns false (not an error) on mismatch -> no throw.
148
+ if (crypto.subtle.verify(new HmacParams(), k, macBytes, msg)) return value;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ // --- encryption ---------------------------------------------------------
154
+
155
+ /** Return the AES-GCM-encrypted sealed value for `name` / `value`. */
156
+ encrypt(name: string, value: string): string {
157
+ const iv = new Uint8Array(IV_LEN);
158
+ crypto.getRandomValues(iv);
159
+
160
+ const k = this.importAes(this.keys[0]);
161
+ const ct = crypto.subtle.encrypt(new AesGcmParams(iv, utf8(name), 128), k, utf8(value));
162
+
163
+ const sealed = new Uint8Array(IV_LEN + ct.length);
164
+ for (let i = 0; i < IV_LEN; i++) sealed[i] = iv[i];
165
+ for (let i = 0; i < ct.length; i++) sealed[IV_LEN + i] = ct[i];
166
+ return base64UrlEncode(sealed);
167
+ }
168
+
169
+ /** Decrypt a sealed value for `name`, returning the plaintext or `null`. */
170
+ decrypt(name: string, sealed: string): string | null {
171
+ const raw = base64UrlDecode(sealed);
172
+ if (raw == null) return null;
173
+ if (raw.length < IV_LEN + TAG_LEN) return null; // need IV + at least the tag
174
+
175
+ const iv = new Uint8Array(IV_LEN);
176
+ for (let i = 0; i < IV_LEN; i++) iv[i] = raw[i];
177
+ const data = raw.subarray(IV_LEN);
178
+ const aad = utf8(name);
179
+
180
+ for (let i = 0; i < this.keys.length; i++) {
181
+ const k = this.importAes(this.keys[i]);
182
+ const params = new AesGcmParams(iv, aad, 128).pack();
183
+ // Raw host call: a bad tag / wrong key returns a negative code, which
184
+ // we turn into `null`. Going through `subtle.decrypt` would throw and
185
+ // (exceptions being disabled) abort the request.
186
+ const len = webcrypto.decrypt(
187
+ k.handle,
188
+ params.dataStart,
189
+ params.byteLength,
190
+ data.dataStart,
191
+ data.byteLength,
192
+ );
193
+ if (len >= 0) {
194
+ const out = new Uint8Array(len);
195
+ if (len > 0) webcrypto.takeResult(out.dataStart, len);
196
+ return fromUtf8(out);
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // --- cookie helpers -----------------------------------------------------
203
+
204
+ /**
205
+ * Seal `cookie`'s value in place (sign or encrypt per this instance's mode)
206
+ * and mark it `Raw` (the sealed value is already cookie-safe base64url).
207
+ * Returns the same cookie for chaining.
208
+ */
209
+ seal(cookie: Cookie): Cookie {
210
+ cookie.value =
211
+ this.mode == MODE_ENCRYPTED
212
+ ? this.encrypt(cookie.name, cookie.value)
213
+ : this.sign(cookie.name, cookie.value);
214
+ cookie.encoding = CookieEncoding.Raw;
215
+ return cookie;
216
+ }
217
+
218
+ /** Read and open cookie `name` from a parsed jar, or `null` if missing/invalid. */
219
+ open(jar: CookieMap, name: string): string | null {
220
+ const sealed = jar.get(name);
221
+ if (sealed == null) return null;
222
+ return this.mode == MODE_ENCRYPTED ? this.decrypt(name, sealed) : this.unsign(name, sealed);
223
+ }
224
+ }
@@ -19,8 +19,25 @@ export { Response, TOIL_UNHANDLED_HEADER } from './response';
19
19
  export { ToilHandler } from './handlers/ToilHandler';
20
20
  export { Server, ServerEnvironment } from './env/Server';
21
21
 
22
+ // Wall-clock (`Time.nowMillis()` / `Time.nowSeconds()`), backed by the host
23
+ // `Date.now()` binding. Ambient global (`@global`), also re-exported here.
24
+ export { Time } from './time';
25
+
26
+ // Edge SSR (`render` entrypoint): the render router + the typed slot-values
27
+ // API a route's `render(req)` fills. See `./exports/render`.
28
+ export { Ssr, SsrRegistry, RenderFn } from './ssr/Ssr';
29
+ export { SlotValues, SlotValue, HtmlBuilder } from './ssr/slots';
30
+
22
31
  // HTTP layer (`@rest` / `@route`).
23
32
  export { Rest, RestRegistry, RouteFn } from './rest/Rest';
24
33
  export { RouteContext } from './rest/RouteContext';
25
34
  export { matchRoute } from './rest/match';
26
35
  export { RestHandler } from './rest/RestHandler';
36
+
37
+ // Cookies (`Cookie` / `Cookies` / `SecureCookies`). These are also ambient
38
+ // globals (`@global`), so a handler can use them with no import; the re-export
39
+ // keeps them importable and pulls the modules into every build.
40
+ export { Cookie, SameSite, CookieEncoding, CookiePrefix, CookieValidation } from './http/cookie';
41
+ export { Cookies, CookieMap } from './http/cookies';
42
+ export { SecureCookies } from './http/securecookies';
43
+ export { base64UrlEncode, base64UrlDecode } from './http/base64';
@@ -4,6 +4,8 @@
4
4
  * memory. See `envelope.ts`.
5
5
  */
6
6
 
7
+ import { Cookies, CookieMap } from './http/cookies';
8
+
7
9
  export enum Method {
8
10
  GET = 0,
9
11
  POST = 1,
@@ -31,6 +33,9 @@ export class Request {
31
33
  headers: Array<Header>;
32
34
  body: Uint8Array;
33
35
 
36
+ // Lazily parsed `Cookie` header, cached for the life of the request.
37
+ private _cookies: CookieMap | null = null;
38
+
34
39
  constructor(method: Method, path: string, headers: Array<Header>, body: Uint8Array) {
35
40
  this.method = method;
36
41
  this.path = path;
@@ -52,4 +57,23 @@ export class Request {
52
57
  }
53
58
  return null;
54
59
  }
60
+
61
+ /**
62
+ * The request's cookies, parsed from the `Cookie` header (values are
63
+ * percent-decoded). Parsed once and cached; an empty map if there is no
64
+ * `Cookie` header.
65
+ */
66
+ cookies(): CookieMap {
67
+ const cached = this._cookies;
68
+ if (cached != null) return cached;
69
+ const h = this.header('cookie');
70
+ const map = h == null ? new CookieMap() : Cookies.parse(h);
71
+ this._cookies = map;
72
+ return map;
73
+ }
74
+
75
+ /** A single cookie value by name, or `null` if absent. */
76
+ cookie(name: string): string | null {
77
+ return this.cookies().get(name);
78
+ }
55
79
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Header } from './request';
8
+ import { Cookie } from './http/cookie';
8
9
 
9
10
  /**
10
11
  * Marker header on the runtime's fallback 404 (no route matched, no handler
@@ -106,4 +107,88 @@ export class Response {
106
107
 
107
108
  return this;
108
109
  }
110
+
111
+ /**
112
+ * Append a `Set-Cookie` for `cookie`. Each call adds its own header entry,
113
+ * so multiple cookies are emitted as separate `Set-Cookie` headers (never
114
+ * folded). Builder-style: returns `this`.
115
+ */
116
+ public setCookie(cookie: Cookie): Response {
117
+ this.headers.push(new Header('set-cookie', cookie.serialize()));
118
+
119
+ return this;
120
+ }
121
+
122
+ /** Shorthand for `setCookie(new Cookie(name, value))` (no attributes). */
123
+ public setCookieKV(name: string, value: string): Response {
124
+ return this.setCookie(new Cookie(name, value));
125
+ }
126
+
127
+ /**
128
+ * Append a `Set-Cookie` that deletes `name`: empty value, `Max-Age=0`, and
129
+ * an epoch `Expires`. `path` (default `/`) and `domain` must match the
130
+ * cookie being cleared for the browser to drop it. Builder-style.
131
+ */
132
+ public clearCookie(name: string, path: string = '/', domain: string = ''): Response {
133
+ const c = new Cookie(name, '').path(path).maxAge(0).expires(0);
134
+ if (domain.length > 0) c.domain(domain);
135
+
136
+ return this.setCookie(c);
137
+ }
138
+
139
+ /**
140
+ * Mark this response cacheable at the toil edge and/or the browser.
141
+ *
142
+ * `edgeTtlMinutes` caches the response on the edge node (per-core,
143
+ * keyed by host + method + path + request body) for up to that many
144
+ * minutes -- the ONLY way a POST response is cached. `browserTtlSeconds`
145
+ * emits a `Cache-Control: max-age` for the client.
146
+ *
147
+ * Host-side safety, enforced no matter what you ask for: the edge TTL
148
+ * is clamped to 24h, only 2xx responses are edge-cached, a response
149
+ * carrying a `Set-Cookie` is never edge-cached, and an AUTHENTICATED
150
+ * request (one with a `Cookie` or `Authorization` header) is not
151
+ * edge-cached unless you pass `allowAuth = true`. Only mark a response
152
+ * cacheable when its body is a pure function of (host, path, body) --
153
+ * never per-user data keyed by a cookie/header that is not in the path
154
+ * or body, or one user's response could be served to another.
155
+ *
156
+ * Builder-style: returns `this`.
157
+ */
158
+ public cache(
159
+ edgeTtlMinutes: u16,
160
+ browserTtlSeconds: u32 = 0,
161
+ privateScope: bool = false,
162
+ allowAuth: bool = false,
163
+ ): Response {
164
+ let v = '';
165
+ if (edgeTtlMinutes > 0) {
166
+ v = 'edge=' + edgeTtlMinutes.toString();
167
+ }
168
+ if (browserTtlSeconds > 0) {
169
+ if (v.length > 0) v += '; ';
170
+ v += 'browser=' + browserTtlSeconds.toString();
171
+ }
172
+ if (privateScope) {
173
+ if (v.length > 0) v += '; ';
174
+ v += 'scope=private';
175
+ }
176
+ if (allowAuth) {
177
+ if (v.length > 0) v += '; ';
178
+ v += 'auth=1';
179
+ }
180
+ if (v.length > 0) {
181
+ this.setHeader('toil-cache-control', v);
182
+ }
183
+
184
+ return this;
185
+ }
186
+
187
+ /**
188
+ * Shorthand for {@link cache}: edge-cache this response for `minutes`
189
+ * minutes (no browser caching).
190
+ */
191
+ public cacheFor(minutes: u16): Response {
192
+ return this.cache(minutes);
193
+ }
109
194
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The auto-populated SSR render router. Every compiler-generated route render
3
+ * self-registers here at module init; the `render` wasm export calls
4
+ * `Ssr.dispatch(req)` to find the one whose path matches. The host has already
5
+ * decided this is a template route, but the guest re-derives WHICH route from
6
+ * the request path (the template name is not in the request envelope), exactly
7
+ * as a `@rest` controller matches its own prefix.
8
+ *
9
+ * First matching render wins; `null` means no route matched (a guest/host
10
+ * coherence problem) and the export emits a fail-safe empty result.
11
+ */
12
+
13
+ import { Request } from '../request';
14
+ import { SlotValues } from './slots';
15
+
16
+ /** A route render: returns filled hole values on a path hit, null on a miss. */
17
+ export type RenderFn = (req: Request) => SlotValues | null;
18
+
19
+ export class SsrRegistry {
20
+ private fns: Array<RenderFn> = new Array<RenderFn>();
21
+
22
+ /** Compiler-injected: registers a route's render. Not for direct use. */
23
+ register(fn: RenderFn): void {
24
+ this.fns.push(fn);
25
+ }
26
+
27
+ /** Try every registered render in registration order; first match wins. */
28
+ dispatch(req: Request): SlotValues | null {
29
+ for (let i = 0; i < this.fns.length; i++) {
30
+ const hit = this.fns[i](req);
31
+ if (hit != null) return hit;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Number of registered renders (diagnostics / tests). */
37
+ get size(): i32 {
38
+ return this.fns.length;
39
+ }
40
+ }
41
+
42
+ /** The process-wide SSR render router singleton. */
43
+ export const Ssr: SsrRegistry = new SsrRegistry();
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Serialiser for the SSR **values envelope** (guest -> host), byte-for-byte
3
+ * compatible with `toil-backend/src/host/template/assemble.rs::decode_values`.
4
+ *
5
+ * Layout (LE, no padding):
6
+ *
7
+ * u16 status
8
+ * [32] template_hash
9
+ * u16 n_headers
10
+ * for each header: u16 name_len, u16 val_len, [u8] name, [u8] val
11
+ * u16 n_slots
12
+ * for each value: u16 slot_id, u8 kind, u32 value_len, [u8] value
13
+ *
14
+ * The host keys values by `slot_id` and inserts each at the manifest-fixed
15
+ * offset, so the guest can never choose where bytes land.
16
+ */
17
+
18
+ import {
19
+ utf8Length,
20
+ writeBytes,
21
+ writeU16,
22
+ writeU32,
23
+ writeU8,
24
+ writeUtf8,
25
+ } from '../memory';
26
+ import { HASH_LEN, SlotValues } from './slots';
27
+
28
+ /** Serialise `v` into linear memory at `dst_ofs`; returns bytes written. On any
29
+ * representability violation (counts/lengths over their field widths, or a
30
+ * wrong-sized hash) a minimal 500 envelope is written instead so the host never
31
+ * sees an encoder fault. */
32
+ export function encodeValues(v: SlotValues, dst_ofs: usize): u32 {
33
+ if (v.templateHash.length != HASH_LEN) return encodeFallback(dst_ofs);
34
+ if (v.headers.length > 0xffff || v.slots.length > 0xffff) return encodeFallback(dst_ofs);
35
+ for (let i = 0; i < v.headers.length; i++) {
36
+ const h = v.headers[i];
37
+ if (utf8Length(h.name) > 0xffff || utf8Length(h.value) > 0xffff) {
38
+ return encodeFallback(dst_ofs);
39
+ }
40
+ }
41
+ for (let i = 0; i < v.slots.length; i++) {
42
+ if (<u64>v.slots[i].bytes.length > <u64>0xffffffff) return encodeFallback(dst_ofs);
43
+ }
44
+
45
+ let cur: usize = dst_ofs;
46
+
47
+ writeU16(cur, v.status);
48
+ cur += 2;
49
+
50
+ for (let i = 0; i < HASH_LEN; i++) {
51
+ writeU8(cur + <usize>i, v.templateHash[i]);
52
+ }
53
+ cur += <usize>HASH_LEN;
54
+
55
+ writeU16(cur, <u16>v.headers.length);
56
+ cur += 2;
57
+ for (let i = 0; i < v.headers.length; i++) {
58
+ const h = v.headers[i];
59
+ writeU16(cur, <u16>utf8Length(h.name));
60
+ cur += 2;
61
+ writeU16(cur, <u16>utf8Length(h.value));
62
+ cur += 2;
63
+ cur += writeUtf8(cur, h.name);
64
+ cur += writeUtf8(cur, h.value);
65
+ }
66
+
67
+ writeU16(cur, <u16>v.slots.length);
68
+ cur += 2;
69
+ for (let i = 0; i < v.slots.length; i++) {
70
+ const s = v.slots[i];
71
+ writeU16(cur, <u16>s.slotId);
72
+ cur += 2;
73
+ writeU8(cur, s.kind);
74
+ cur += 1;
75
+ writeU32(cur, <u32>s.bytes.length);
76
+ cur += 4;
77
+ cur += writeBytes(cur, s.bytes);
78
+ }
79
+
80
+ return <u32>(cur - dst_ofs);
81
+ }
82
+
83
+ /** Upper bound on the encoded size of `v`, used to size the response slot the
84
+ * `render` export allocates before encoding. */
85
+ export function valuesEncodedBound(v: SlotValues): usize {
86
+ // status + hash + n_headers + n_slots field overheads.
87
+ let bound: usize = 2 + <usize>HASH_LEN + 2 + 2;
88
+ for (let i = 0; i < v.headers.length; i++) {
89
+ const h = v.headers[i];
90
+ bound += 4 + <usize>utf8Length(h.name) + <usize>utf8Length(h.value);
91
+ }
92
+ for (let i = 0; i < v.slots.length; i++) {
93
+ bound += 7 + <usize>v.slots[i].bytes.length;
94
+ }
95
+ return bound;
96
+ }
97
+
98
+ function encodeFallback(dst_ofs: usize): u32 {
99
+ // status=500, zeroed 32-byte hash (host rejects as a coherence mismatch),
100
+ // 0 headers, 0 slots.
101
+ writeU16(dst_ofs, 500);
102
+ let cur = dst_ofs + 2;
103
+ for (let i = 0; i < HASH_LEN; i++) {
104
+ writeU8(cur + <usize>i, 0);
105
+ }
106
+ cur += <usize>HASH_LEN;
107
+ writeU16(cur, 0);
108
+ writeU16(cur + 2, 0);
109
+ return <u32>(2 + HASH_LEN + 2 + 2);
110
+ }