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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +124 -7
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +179 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.d.ts +8 -0
- package/build/devserver/cache.js +0 -0
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/index.js +10 -1
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +135 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/README.md +19 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +290 -0
- package/examples/basic/server/core/store.ts +31 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +13 -2
- package/examples/basic/server/models/NewPlayer.ts +5 -0
- package/examples/basic/server/models/Player.ts +8 -0
- package/examples/basic/server/models/ScoreDelta.ts +5 -0
- package/examples/basic/server/models/Standings.ts +7 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/Leaderboard.ts +20 -0
- package/examples/basic/server/routes/Players.ts +53 -0
- package/examples/basic/server/routes/PqDemo.ts +109 -0
- package/examples/basic/server/routes/Session.ts +73 -0
- package/examples/basic/server/scheduled/README.md +7 -0
- package/examples/basic/server/services/Stats.ts +11 -0
- package/examples/basic/server/services/remotes.ts +7 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +85 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +159 -14
- package/src/client/auth.ts +322 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/index.ts +21 -1
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +48 -4
- package/test/fixtures/bignum-wire/spec.ts +27 -0
- package/test/rpc-bignum-wire.test.ts +164 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
- package/examples/basic/server/HelloHandler.ts +0 -42
- 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
|
+
}
|
package/server/runtime/index.ts
CHANGED
|
@@ -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
|
+
}
|