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,416 @@
1
+ /**
2
+ * `Cookie` — a fluent builder, serializer, and validator for a single
3
+ * `Set-Cookie`, covering the full RFC 6265bis attribute set plus the
4
+ * `Partitioned` (CHIPS) and `Priority` attributes.
5
+ *
6
+ * Exposed as an ambient global (`@global`, no import needed in a handler) and
7
+ * also exported from `toiljs/server/runtime`. Pairs with `Cookies` (parsing /
8
+ * the request side) and `SecureCookies` (signing / encryption).
9
+ *
10
+ * ```ts
11
+ * resp.setCookie(
12
+ * Cookie.create('sid', token)
13
+ * .httpOnly()
14
+ * .secure()
15
+ * .sameSite(SameSite.Lax)
16
+ * .maxAge(3600)
17
+ * .asHostPrefixed(),
18
+ * );
19
+ * ```
20
+ */
21
+
22
+ import { imfFixdate } from './date';
23
+ import { base64UrlEncode } from './base64';
24
+ import { percentEncode } from './percent';
25
+
26
+ /** `SameSite` attribute. `Default` omits the attribute (the UA applies Lax). */
27
+ @global
28
+ export enum SameSite {
29
+ Default = 0,
30
+ None = 1,
31
+ Lax = 2,
32
+ Strict = 3,
33
+ }
34
+
35
+ /** How a cookie value is encoded onto the wire. */
36
+ @global
37
+ export enum CookieEncoding {
38
+ /** `encodeURIComponent`-style percent-encoding (default): arbitrary UTF-8 is safe. */
39
+ Percent = 0,
40
+ /** No transformation. The value must already be valid `cookie-octet`. */
41
+ Raw = 1,
42
+ /** UTF-8 then unpadded base64url. */
43
+ Base64Url = 2,
44
+ }
45
+
46
+ /** Cookie name prefix with browser-enforced guarantees (RFC 6265bis §4.1.3). */
47
+ @global
48
+ export enum CookiePrefix {
49
+ None = 0,
50
+ /** `__Secure-`: requires `Secure`. */
51
+ Secure = 1,
52
+ /** `__Host-`: requires `Secure`, `Path=/`, and no `Domain`. */
53
+ Host = 2,
54
+ }
55
+
56
+ /** SHOULD-NOT-exceed lifetime cap from RFC 6265bis §5.5: 400 days, in seconds. */
57
+ export const MAX_LIFETIME_SECONDS: i64 = 34560000;
58
+
59
+ // --- grammar predicates -----------------------------------------------------
60
+
61
+ /** RFC 7230 `token` char: ALPHA / DIGIT / "!#$%&'*+-.^_`|~". */
62
+ function isTokenChar(c: i32): bool {
63
+ if (c >= 65 && c <= 90) return true; // A-Z
64
+ if (c >= 97 && c <= 122) return true; // a-z
65
+ if (c >= 48 && c <= 57) return true; // 0-9
66
+ return (
67
+ c == 33 || c == 35 || c == 36 || c == 37 || c == 38 || c == 39 ||
68
+ c == 42 || c == 43 || c == 45 || c == 46 || c == 94 || c == 95 ||
69
+ c == 96 || c == 124 || c == 126
70
+ );
71
+ }
72
+
73
+ function isToken(s: string): bool {
74
+ if (s.length == 0) return false;
75
+ for (let i = 0; i < s.length; i++) {
76
+ if (!isTokenChar(s.charCodeAt(i))) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /** RFC 6265bis `cookie-octet`: %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E. */
82
+ export function isCookieOctet(c: i32): bool {
83
+ return (
84
+ c == 0x21 ||
85
+ (c >= 0x23 && c <= 0x2b) ||
86
+ (c >= 0x2d && c <= 0x3a) ||
87
+ (c >= 0x3c && c <= 0x5b) ||
88
+ (c >= 0x5d && c <= 0x7e)
89
+ );
90
+ }
91
+
92
+ function allCookieOctet(s: string): bool {
93
+ for (let i = 0; i < s.length; i++) {
94
+ if (!isCookieOctet(s.charCodeAt(i))) return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Keep only RFC 7230 `token` characters. A cookie name MUST be a token, so this
101
+ * drops anything (CR/LF, `;`, `=`, whitespace, ...) that could break out of the
102
+ * name and inject an attribute or a header. Fast path returns a clean input as-is.
103
+ */
104
+ function tokenize(s: string): string {
105
+ let clean = true;
106
+ for (let i = 0; i < s.length; i++) {
107
+ if (!isTokenChar(s.charCodeAt(i))) {
108
+ clean = false;
109
+ break;
110
+ }
111
+ }
112
+ if (clean) return s;
113
+ let out = '';
114
+ for (let i = 0; i < s.length; i++) {
115
+ const c = s.charCodeAt(i);
116
+ if (isTokenChar(c)) out += String.fromCharCode(c);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Strip the characters that could break out of a value or attribute on the wire:
123
+ * control characters (C0 + DEL), which enable CR/LF header injection, and `;`,
124
+ * which would otherwise start a new cookie attribute. Defense in depth: the
125
+ * default percent encoding already removes these from the value, and they are
126
+ * invalid in these positions per the cookie grammar, so nothing legitimate is
127
+ * lost (base64url sealed values are unaffected). Fast path returns a clean input.
128
+ */
129
+ function stripUnsafe(s: string): string {
130
+ let clean = true;
131
+ for (let i = 0; i < s.length; i++) {
132
+ const c = s.charCodeAt(i);
133
+ if (c < 0x20 || c == 0x7f || c == 0x3b) {
134
+ clean = false;
135
+ break;
136
+ }
137
+ }
138
+ if (clean) return s;
139
+ let out = '';
140
+ for (let i = 0; i < s.length; i++) {
141
+ const c = s.charCodeAt(i);
142
+ if (c >= 0x20 && c != 0x7f && c != 0x3b) out += String.fromCharCode(c);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** Result of {@link Cookie#validate}: `valid` plus the list of problems found. */
148
+ export class CookieValidation {
149
+ valid: bool = true;
150
+ errors: Array<string> = new Array<string>();
151
+
152
+ fail(msg: string): void {
153
+ this.valid = false;
154
+ this.errors.push(msg);
155
+ }
156
+ }
157
+
158
+ @global
159
+ export class Cookie {
160
+ /** Cookie name (a token; never encoded). */
161
+ name: string;
162
+ /** Logical cookie value (encoded per {@link encoding} on serialize). */
163
+ value: string;
164
+ /** Wire encoding applied to {@link value} by {@link serialize}. */
165
+ encoding: CookieEncoding = CookieEncoding.Percent;
166
+
167
+ private _domain: string = '';
168
+ private _path: string = '';
169
+ private _maxAge: i64 = 0;
170
+ private _hasMaxAge: bool = false;
171
+ private _expires: i64 = 0;
172
+ private _hasExpires: bool = false;
173
+ private _expiresRaw: string = '';
174
+ private _secure: bool = false;
175
+ private _httpOnly: bool = false;
176
+ private _sameSite: SameSite = SameSite.Default;
177
+ private _partitioned: bool = false;
178
+ private _priority: string = '';
179
+ private _extensions: Array<string> = new Array<string>();
180
+
181
+ constructor(name: string, value: string) {
182
+ this.name = name;
183
+ this.value = value;
184
+ }
185
+
186
+ /** Construct a cookie. Builder-style alias for `new Cookie(name, value)`. */
187
+ static create(name: string, value: string): Cookie {
188
+ return new Cookie(name, value);
189
+ }
190
+
191
+ // --- fluent attribute setters (each returns `this`) ---------------------
192
+
193
+ /** `Domain` attribute. */
194
+ domain(v: string): Cookie {
195
+ this._domain = v;
196
+ return this;
197
+ }
198
+
199
+ /** `Path` attribute (must begin with `/`). */
200
+ path(v: string): Cookie {
201
+ this._path = v;
202
+ return this;
203
+ }
204
+
205
+ /** `Max-Age` in seconds. `0` / negative expire the cookie immediately. */
206
+ maxAge(seconds: i64): Cookie {
207
+ this._maxAge = seconds;
208
+ this._hasMaxAge = true;
209
+ return this;
210
+ }
211
+
212
+ /** `Expires` from a Unix timestamp (seconds), formatted as an IMF-fixdate. */
213
+ expires(epochSeconds: i64): Cookie {
214
+ this._expires = epochSeconds;
215
+ this._hasExpires = true;
216
+ return this;
217
+ }
218
+
219
+ /** `Expires` as a verbatim date string (escape hatch; not validated). */
220
+ expiresRaw(date: string): Cookie {
221
+ this._expiresRaw = date;
222
+ return this;
223
+ }
224
+
225
+ /** `Secure` attribute. */
226
+ secure(on: bool = true): Cookie {
227
+ this._secure = on;
228
+ return this;
229
+ }
230
+
231
+ /** `HttpOnly` attribute. */
232
+ httpOnly(on: bool = true): Cookie {
233
+ this._httpOnly = on;
234
+ return this;
235
+ }
236
+
237
+ /** `SameSite` attribute. */
238
+ sameSite(s: SameSite): Cookie {
239
+ this._sameSite = s;
240
+ return this;
241
+ }
242
+
243
+ /** `Partitioned` attribute (CHIPS). Implies `Secure` on serialize. */
244
+ partitioned(on: bool = true): Cookie {
245
+ this._partitioned = on;
246
+ return this;
247
+ }
248
+
249
+ /** `Priority` attribute (`Low` / `Medium` / `High`). */
250
+ priority(p: string): Cookie {
251
+ this._priority = p;
252
+ return this;
253
+ }
254
+
255
+ /** Append a raw extension attribute verbatim, e.g. `extension('CustomFlag')`. */
256
+ extension(av: string): Cookie {
257
+ this._extensions.push(av);
258
+ return this;
259
+ }
260
+
261
+ /** Choose the wire encoding for the value. */
262
+ withEncoding(e: CookieEncoding): Cookie {
263
+ this.encoding = e;
264
+ return this;
265
+ }
266
+
267
+ // --- prefixes -----------------------------------------------------------
268
+
269
+ /** Apply the `__Secure-` prefix and force `Secure`. */
270
+ asSecurePrefixed(): Cookie {
271
+ if (this.name.indexOf('__Secure-') != 0) this.name = '__Secure-' + this.name;
272
+ this._secure = true;
273
+ return this;
274
+ }
275
+
276
+ /** Apply the `__Host-` prefix and force `Secure`, `Path=/`, and no `Domain`. */
277
+ asHostPrefixed(): Cookie {
278
+ if (this.name.indexOf('__Host-') != 0) this.name = '__Host-' + this.name;
279
+ this._secure = true;
280
+ this._path = '/';
281
+ this._domain = '';
282
+ return this;
283
+ }
284
+
285
+ // --- helpers ------------------------------------------------------------
286
+
287
+ /** SameSite=None and Partitioned both require Secure; reflect that here. */
288
+ private effectiveSecure(): bool {
289
+ return this._secure || this._sameSite == SameSite.None || this._partitioned;
290
+ }
291
+
292
+ /** The name prefix detected case-insensitively (RFC 6265bis §5.4). */
293
+ detectedPrefix(): CookiePrefix {
294
+ const lower = this.name.toLowerCase();
295
+ if (lower.startsWith('__host-')) return CookiePrefix.Host;
296
+ if (lower.startsWith('__secure-')) return CookiePrefix.Secure;
297
+ return CookiePrefix.None;
298
+ }
299
+
300
+ /** The value transformed per {@link encoding}, ready for the wire. */
301
+ encodedValue(): string {
302
+ if (this.encoding == CookieEncoding.Raw) return this.value;
303
+ if (this.encoding == CookieEncoding.Base64Url) {
304
+ return base64UrlEncode(Uint8Array.wrap(String.UTF8.encode(this.value)));
305
+ }
306
+ return percentEncode(this.value);
307
+ }
308
+
309
+ /**
310
+ * Validate against RFC 6265bis: name token, name+value ≤ 4096 bytes,
311
+ * attribute sizes, `Path` form, prefix guarantees, `SameSite=None`/
312
+ * `Partitioned` ⇒ `Secure`, and the 400-day lifetime cap.
313
+ */
314
+ validate(): CookieValidation {
315
+ const v = new CookieValidation();
316
+
317
+ if (!isToken(this.name)) {
318
+ v.fail('invalid cookie name (must be a non-empty RFC token): "' + this.name + '"');
319
+ }
320
+
321
+ const enc = this.encodedValue();
322
+ if (String.UTF8.byteLength(this.name) + String.UTF8.byteLength(enc) > 4096) {
323
+ v.fail('cookie name+value exceeds the 4096-byte limit');
324
+ }
325
+ if (this.encoding == CookieEncoding.Raw && !allCookieOctet(enc)) {
326
+ v.fail('raw cookie value contains characters outside cookie-octet');
327
+ }
328
+
329
+ if (this._domain.length > 0 && String.UTF8.byteLength(this._domain) > 1024) {
330
+ v.fail('Domain exceeds the 1024-byte limit');
331
+ }
332
+ if (this._path.length > 0) {
333
+ if (!this._path.startsWith('/')) v.fail("Path must start with '/'");
334
+ if (String.UTF8.byteLength(this._path) > 1024) v.fail('Path exceeds the 1024-byte limit');
335
+ }
336
+
337
+ const prefix = this.detectedPrefix();
338
+ const secure = this.effectiveSecure();
339
+ if (prefix == CookiePrefix.Secure && !secure) {
340
+ v.fail('__Secure- prefix requires the Secure attribute');
341
+ }
342
+ if (prefix == CookiePrefix.Host) {
343
+ if (!secure) v.fail('__Host- prefix requires the Secure attribute');
344
+ if (this._domain.length > 0) v.fail('__Host- prefix forbids the Domain attribute');
345
+ if (this._path != '/') v.fail('__Host- prefix requires Path=/');
346
+ }
347
+ if (this._sameSite == SameSite.None && !secure) {
348
+ v.fail('SameSite=None requires the Secure attribute');
349
+ }
350
+ if (this._partitioned && !secure) {
351
+ v.fail('Partitioned requires the Secure attribute');
352
+ }
353
+ if (this._hasMaxAge && this._maxAge > MAX_LIFETIME_SECONDS) {
354
+ v.fail('Max-Age exceeds the 400-day cap (clamped on serialize)');
355
+ }
356
+
357
+ return v;
358
+ }
359
+
360
+ /**
361
+ * Serialize to a `Set-Cookie` field value. Lenient by default (always emits
362
+ * a best-effort cookie); pass `strict = true` to throw on a hard violation.
363
+ * `Secure` is emitted automatically when `SameSite=None` or `Partitioned` is
364
+ * set, and `Max-Age` is clamped to the 400-day cap.
365
+ */
366
+ serialize(strict: bool = false): string {
367
+ if (strict) {
368
+ const v = this.validate();
369
+ if (!v.valid) {
370
+ throw new Error('invalid cookie: ' + (v.errors.length > 0 ? v.errors[0] : 'unknown'));
371
+ }
372
+ }
373
+
374
+ // Sanitize every caller-supplied part to its grammar as defense in depth
375
+ // against header injection (CR/LF) and cookie-attribute injection (`;`).
376
+ // The name is reduced to RFC token characters; values and attribute
377
+ // values have controls and `;` stripped. These are invalid in these
378
+ // positions per the cookie grammar, so nothing legitimate is dropped, and
379
+ // base64url sealed values pass through untouched.
380
+ let s = tokenize(this.name) + '=' + stripUnsafe(this.encodedValue());
381
+
382
+ if (this._domain.length > 0) s += '; Domain=' + stripUnsafe(this._domain);
383
+ if (this._path.length > 0) s += '; Path=' + stripUnsafe(this._path);
384
+
385
+ if (this._expiresRaw.length > 0) {
386
+ s += '; Expires=' + stripUnsafe(this._expiresRaw);
387
+ } else if (this._hasExpires) {
388
+ s += '; Expires=' + imfFixdate(this._expires);
389
+ }
390
+
391
+ if (this._hasMaxAge) {
392
+ let age = this._maxAge;
393
+ if (age > MAX_LIFETIME_SECONDS) age = MAX_LIFETIME_SECONDS;
394
+ s += '; Max-Age=' + age.toString();
395
+ }
396
+
397
+ if (this._sameSite == SameSite.Strict) s += '; SameSite=Strict';
398
+ else if (this._sameSite == SameSite.Lax) s += '; SameSite=Lax';
399
+ else if (this._sameSite == SameSite.None) s += '; SameSite=None';
400
+
401
+ if (this.effectiveSecure()) s += '; Secure';
402
+ if (this._httpOnly) s += '; HttpOnly';
403
+ if (this._partitioned) s += '; Partitioned';
404
+ if (this._priority.length > 0) s += '; Priority=' + stripUnsafe(this._priority);
405
+
406
+ for (let i = 0; i < this._extensions.length; i++) {
407
+ s += '; ' + stripUnsafe(this._extensions[i]);
408
+ }
409
+
410
+ return s;
411
+ }
412
+
413
+ toString(): string {
414
+ return this.serialize();
415
+ }
416
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `Cookies` — parsing and codec helpers, and `CookieMap`, the result of parsing
3
+ * a request `Cookie` header. The write side (building a `Set-Cookie`) lives on
4
+ * the {@link Cookie} builder; this is the read side plus a one-shot serializer.
5
+ *
6
+ * Both `Cookies` and `CookieMap` are ambient globals (`@global`, no import) and
7
+ * are also exported from `toiljs/server/runtime`.
8
+ */
9
+
10
+ import { Cookie, SameSite, CookieEncoding } from './cookie';
11
+ import { percentEncode, percentDecode } from './percent';
12
+
13
+ /** Parse a base-10 signed integer (leading `+`/`-`, then digits), lenient. */
14
+ function parseI64(s: string): i64 {
15
+ let i = 0;
16
+ let neg = false;
17
+ if (s.length > 0 && (s.charCodeAt(0) == 45 || s.charCodeAt(0) == 43)) {
18
+ neg = s.charCodeAt(0) == 45;
19
+ i = 1;
20
+ }
21
+ let r: i64 = 0;
22
+ for (; i < s.length; i++) {
23
+ const c = s.charCodeAt(i);
24
+ if (c < 48 || c > 57) break;
25
+ r = r * 10 + <i64>(c - 48);
26
+ }
27
+ return neg ? -r : r;
28
+ }
29
+
30
+ /** Strip one layer of surrounding DQUOTEs, as servers conventionally do on read. */
31
+ function unquote(s: string): string {
32
+ if (s.length >= 2 && s.charCodeAt(0) == 34 && s.charCodeAt(s.length - 1) == 34) {
33
+ return s.substring(1, s.length - 1);
34
+ }
35
+ return s;
36
+ }
37
+
38
+ /**
39
+ * An ordered, name→value view of the cookies on a request. Backed by parallel
40
+ * arrays (a request carries a handful of cookies; the linear scan beats hashing
41
+ * and keeps the codec-free runtime small, matching `RouteContext`). On a
42
+ * duplicate name the first occurrence wins (it is the most specific per
43
+ * RFC 6265bis ordering).
44
+ */
45
+ @global
46
+ export class CookieMap {
47
+ private keys: Array<string> = new Array<string>();
48
+ private vals: Array<string> = new Array<string>();
49
+
50
+ /** Insert unless `name` is already present (keep-first). */
51
+ set(name: string, value: string): void {
52
+ for (let i = 0; i < this.keys.length; i++) {
53
+ if (this.keys[i] == name) return;
54
+ }
55
+ this.keys.push(name);
56
+ this.vals.push(value);
57
+ }
58
+
59
+ /** The value for `name`, or `null` if absent. */
60
+ get(name: string): string | null {
61
+ for (let i = 0; i < this.keys.length; i++) {
62
+ if (this.keys[i] == name) return this.vals[i];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ has(name: string): bool {
68
+ for (let i = 0; i < this.keys.length; i++) {
69
+ if (this.keys[i] == name) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** A copy of the cookie names, in encounter order. */
75
+ names(): Array<string> {
76
+ const out = new Array<string>();
77
+ for (let i = 0; i < this.keys.length; i++) out.push(this.keys[i]);
78
+ return out;
79
+ }
80
+
81
+ get size(): i32 {
82
+ return this.keys.length;
83
+ }
84
+ }
85
+
86
+ @global
87
+ export class Cookies {
88
+ /**
89
+ * Parse a request `Cookie` header (`a=1; b=2`) into a {@link CookieMap}.
90
+ * Values are percent-decoded (the inverse of the default `Cookie` encoding)
91
+ * and one layer of surrounding quotes is stripped. Malformed pairs and
92
+ * empty names are skipped, never thrown.
93
+ */
94
+ static parse(cookieHeader: string): CookieMap {
95
+ const map = new CookieMap();
96
+ if (cookieHeader.length == 0) return map;
97
+
98
+ const parts = cookieHeader.split(';');
99
+ for (let i = 0; i < parts.length; i++) {
100
+ const pair = parts[i].trim();
101
+ if (pair.length == 0) continue;
102
+
103
+ const eq = pair.indexOf('=');
104
+ let name: string;
105
+ let rawVal: string;
106
+ if (eq < 0) {
107
+ name = pair;
108
+ rawVal = '';
109
+ } else {
110
+ name = pair.substring(0, eq).trim();
111
+ rawVal = pair.substring(eq + 1).trim();
112
+ }
113
+ if (name.length == 0) continue;
114
+
115
+ map.set(name, percentDecode(unquote(rawVal)));
116
+ }
117
+ return map;
118
+ }
119
+
120
+ /** Shorthand: parse `cookieHeader` and return the value for `name`, or `null`. */
121
+ static get(cookieHeader: string, name: string): string | null {
122
+ return Cookies.parse(cookieHeader).get(name);
123
+ }
124
+
125
+ /**
126
+ * One-shot `Set-Cookie` value for `name=value` with no attributes
127
+ * (percent-encoded). For attributes, build a {@link Cookie} and call
128
+ * `cookie.serialize()`.
129
+ */
130
+ static serialize(name: string, value: string): string {
131
+ return new Cookie(name, value).serialize();
132
+ }
133
+
134
+ /**
135
+ * Parse a `Set-Cookie` field value back into a {@link Cookie} (for clients,
136
+ * tests, or proxies). The value is kept verbatim (`CookieEncoding.Raw`) so
137
+ * re-serializing reproduces the original wire form.
138
+ */
139
+ static parseSetCookie(setCookie: string): Cookie {
140
+ const parts = setCookie.split(';');
141
+ const first = parts.length > 0 ? parts[0].trim() : '';
142
+ const eq = first.indexOf('=');
143
+ let name: string;
144
+ let rawVal: string;
145
+ if (eq < 0) {
146
+ name = first;
147
+ rawVal = '';
148
+ } else {
149
+ name = first.substring(0, eq).trim();
150
+ rawVal = first.substring(eq + 1).trim();
151
+ }
152
+
153
+ const c = new Cookie(name, rawVal);
154
+ c.encoding = CookieEncoding.Raw;
155
+
156
+ for (let i = 1; i < parts.length; i++) {
157
+ const av = parts[i].trim();
158
+ if (av.length == 0) continue;
159
+ const aeq = av.indexOf('=');
160
+ let an: string;
161
+ let avv: string;
162
+ if (aeq < 0) {
163
+ an = av.toLowerCase();
164
+ avv = '';
165
+ } else {
166
+ an = av.substring(0, aeq).trim().toLowerCase();
167
+ avv = av.substring(aeq + 1).trim();
168
+ }
169
+
170
+ if (an == 'domain') c.domain(avv);
171
+ else if (an == 'path') c.path(avv);
172
+ else if (an == 'max-age') c.maxAge(parseI64(avv));
173
+ else if (an == 'expires') c.expiresRaw(avv);
174
+ else if (an == 'samesite') {
175
+ const lv = avv.toLowerCase();
176
+ if (lv == 'strict') c.sameSite(SameSite.Strict);
177
+ else if (lv == 'lax') c.sameSite(SameSite.Lax);
178
+ else if (lv == 'none') c.sameSite(SameSite.None);
179
+ } else if (an == 'secure') c.secure(true);
180
+ else if (an == 'httponly') c.httpOnly(true);
181
+ else if (an == 'partitioned') c.partitioned(true);
182
+ else if (an == 'priority') c.priority(avv);
183
+ else c.extension(av); // unknown extension attribute, kept verbatim
184
+ }
185
+ return c;
186
+ }
187
+
188
+ /** Percent-encode a value as the default `Cookie` encoding would. */
189
+ static encodeValue(raw: string): string {
190
+ return percentEncode(raw);
191
+ }
192
+
193
+ /** Percent-decode a value (the inverse of {@link encodeValue}). */
194
+ static decodeValue(enc: string): string {
195
+ return percentDecode(enc);
196
+ }
197
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * IMF-fixdate formatting for the cookie `Expires` attribute, e.g.
3
+ * `Sun, 06 Nov 1994 08:49:37 GMT` (RFC 9110 §5.6.7, the only date format
4
+ * RFC 6265bis permits a server to emit).
5
+ *
6
+ * Pure integer math (no `Date` dependency) so it is deterministic and unit
7
+ * testable: the civil-from-days conversion is Howard Hinnant's algorithm,
8
+ * valid across the whole proleptic Gregorian range. Internal to the cookie
9
+ * library (not a global).
10
+ */
11
+
12
+ const DOW: string[] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
13
+ const MON: string[] = [
14
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
15
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
16
+ ];
17
+
18
+ function pad2(n: i32): string {
19
+ return n < 10 ? '0' + n.toString() : n.toString();
20
+ }
21
+
22
+ /**
23
+ * Format `epochSeconds` (Unix time, seconds since 1970-01-01T00:00:00Z) as an
24
+ * IMF-fixdate string in GMT. Handles negative inputs (pre-epoch) correctly via
25
+ * floored division.
26
+ */
27
+ export function imfFixdate(epochSeconds: i64): string {
28
+ // Floored division into whole days + remaining seconds-of-day.
29
+ let days: i64 = epochSeconds / 86400;
30
+ let secs: i64 = epochSeconds % 86400;
31
+ if (secs < 0) {
32
+ secs += 86400;
33
+ days -= 1;
34
+ }
35
+
36
+ // 1970-01-01 is a Thursday (index 4 with Sun=0). Positive modulo.
37
+ let wd = <i32>(((days % 7) + 4) % 7);
38
+ if (wd < 0) wd += 7;
39
+
40
+ // Civil date from day count (Hinnant). Shift epoch to 0000-03-01.
41
+ const z: i64 = days + 719468;
42
+ const era: i64 = (z >= 0 ? z : z - 146096) / 146097;
43
+ const doe: i64 = z - era * 146097; // [0, 146096]
44
+ const yoe: i64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
45
+ const y: i64 = yoe + era * 400;
46
+ const doy: i64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
47
+ const mp: i64 = (5 * doy + 2) / 153; // [0, 11]
48
+ const d: i64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
49
+ const m: i64 = mp < 10 ? mp + 3 : mp - 9; // [1, 12]
50
+ const year: i64 = y + (m <= 2 ? 1 : 0);
51
+
52
+ const hour = <i32>(secs / 3600);
53
+ const minute = <i32>((secs % 3600) / 60);
54
+ const second = <i32>(secs % 60);
55
+
56
+ return (
57
+ DOW[wd] +
58
+ ', ' +
59
+ pad2(<i32>d) +
60
+ ' ' +
61
+ MON[<i32>m - 1] +
62
+ ' ' +
63
+ year.toString() +
64
+ ' ' +
65
+ pad2(hour) +
66
+ ':' +
67
+ pad2(minute) +
68
+ ':' +
69
+ pad2(second) +
70
+ ' GMT'
71
+ );
72
+ }