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,302 @@
|
|
|
1
|
+
// Pure cookie logic (builder, parser, codec, validation, Request/Response
|
|
2
|
+
// integration). Imports the specific modules rather than the runtime index so
|
|
3
|
+
// `securecookies.ts` is not pulled into the as-pect graph: it depends on the
|
|
4
|
+
// toilscript crypto std (`crypto` / `data` / `bindings/webcrypto`), which the
|
|
5
|
+
// as-pect compiler (`@btc-vision/assemblyscript`) does not ship. `SecureCookies`
|
|
6
|
+
// is exercised end-to-end against the real toilscript-compiled wasm in
|
|
7
|
+
// `test/devserver.test.ts`.
|
|
8
|
+
import { Method, Request, Header } from '../../server/runtime/request';
|
|
9
|
+
import { Response } from '../../server/runtime/response';
|
|
10
|
+
import { Cookie, SameSite, CookieEncoding } from '../../server/runtime/http/cookie';
|
|
11
|
+
import { Cookies } from '../../server/runtime/http/cookies';
|
|
12
|
+
|
|
13
|
+
// --- helpers ----------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function headerValue(r: Response, name: string): string {
|
|
16
|
+
for (let i = 0; i < r.headers.length; i++) {
|
|
17
|
+
if (r.headers[i].name == name) return r.headers[i].value;
|
|
18
|
+
}
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function headerCount(r: Response, name: string): i32 {
|
|
23
|
+
let n = 0;
|
|
24
|
+
for (let i = 0; i < r.headers.length; i++) {
|
|
25
|
+
if (r.headers[i].name == name) n++;
|
|
26
|
+
}
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reqWithCookie(value: string): Request {
|
|
31
|
+
const headers = new Array<Header>();
|
|
32
|
+
headers.push(new Header('Cookie', value));
|
|
33
|
+
return new Request(Method.GET, '/', headers, new Uint8Array(0));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Cookie builder / serialize --------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('Cookie.serialize', () => {
|
|
39
|
+
it('serializes a bare name=value', () => {
|
|
40
|
+
expect<string>(Cookie.create('a', 'b').serialize()).toStrictEqual('a=b');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('percent-encodes the value by default', () => {
|
|
44
|
+
expect<string>(Cookie.create('x', 'hello world').serialize()).toStrictEqual('x=hello%20world');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('leaves a Raw value untouched', () => {
|
|
48
|
+
expect<string>(
|
|
49
|
+
Cookie.create('x', 'abc').withEncoding(CookieEncoding.Raw).serialize(),
|
|
50
|
+
).toStrictEqual('x=abc');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('base64url-encodes when asked', () => {
|
|
54
|
+
expect<string>(
|
|
55
|
+
Cookie.create('x', 'hi').withEncoding(CookieEncoding.Base64Url).serialize(),
|
|
56
|
+
).toStrictEqual('x=aGk');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('emits attributes in a stable order', () => {
|
|
60
|
+
const s = Cookie.create('a', 'b')
|
|
61
|
+
.domain('example.com')
|
|
62
|
+
.path('/p')
|
|
63
|
+
.secure()
|
|
64
|
+
.httpOnly()
|
|
65
|
+
.sameSite(SameSite.Lax)
|
|
66
|
+
.maxAge(60)
|
|
67
|
+
.serialize();
|
|
68
|
+
expect<string>(s).toStrictEqual('a=b; Domain=example.com; Path=/p; Max-Age=60; SameSite=Lax; Secure; HttpOnly');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('auto-adds Secure for SameSite=None', () => {
|
|
72
|
+
expect<string>(Cookie.create('a', 'b').sameSite(SameSite.None).serialize()).toStrictEqual(
|
|
73
|
+
'a=b; SameSite=None; Secure',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('auto-adds Secure for Partitioned (CHIPS)', () => {
|
|
78
|
+
expect<string>(Cookie.create('a', 'b').partitioned().serialize()).toStrictEqual(
|
|
79
|
+
'a=b; Secure; Partitioned',
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('formats Expires from epoch seconds as an IMF-fixdate', () => {
|
|
84
|
+
expect<string>(Cookie.create('a', 'b').expires(784111777).serialize()).toStrictEqual(
|
|
85
|
+
'a=b; Expires=Sun, 06 Nov 1994 08:49:37 GMT',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('formats the epoch as the IMF-fixdate zero point', () => {
|
|
90
|
+
expect<string>(Cookie.create('a', 'b').expires(0).serialize()).toStrictEqual(
|
|
91
|
+
'a=b; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('clamps Max-Age to the 400-day cap', () => {
|
|
96
|
+
expect<string>(Cookie.create('a', 'b').maxAge(99999999).serialize()).toStrictEqual(
|
|
97
|
+
'a=b; Max-Age=34560000',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('applies the __Host- prefix with its required attributes', () => {
|
|
102
|
+
expect<string>(Cookie.create('sid', 'x').asHostPrefixed().serialize()).toStrictEqual(
|
|
103
|
+
'__Host-sid=x; Path=/; Secure',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('applies the __Secure- prefix', () => {
|
|
108
|
+
expect<string>(Cookie.create('sid', 'x').asSecurePrefixed().serialize()).toStrictEqual(
|
|
109
|
+
'__Secure-sid=x; Secure',
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('emits Priority and extension attributes', () => {
|
|
114
|
+
expect<string>(
|
|
115
|
+
Cookie.create('a', 'b').priority('High').extension('CustomFlag').serialize(),
|
|
116
|
+
).toStrictEqual('a=b; Priority=High; CustomFlag');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('percent-encodes CR/LF in a default-encoded value (no header injection)', () => {
|
|
120
|
+
expect<string>(Cookie.create('a', 'b\r\nc').serialize()).toStrictEqual('a=b%0D%0Ac');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('strips control characters from a raw value', () => {
|
|
124
|
+
expect<string>(
|
|
125
|
+
Cookie.create('a', 'b\r\nInjected').withEncoding(CookieEncoding.Raw).serialize(),
|
|
126
|
+
).toStrictEqual('a=bInjected');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('strips control characters from the name', () => {
|
|
130
|
+
expect<string>(new Cookie('a\r\nb', 'v').serialize()).toStrictEqual('ab=v');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('strips semicolons from a raw value (no attribute injection)', () => {
|
|
134
|
+
expect<string>(
|
|
135
|
+
Cookie.create('a', 'b; Secure').withEncoding(CookieEncoding.Raw).serialize(),
|
|
136
|
+
).toStrictEqual('a=b Secure');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('reduces the name to token chars (no attribute injection)', () => {
|
|
140
|
+
expect<string>(new Cookie('a;Domain=evil', 'v').serialize()).toStrictEqual('aDomainevil=v');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- validation -------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe('Cookie.validate', () => {
|
|
147
|
+
it('accepts a well-formed cookie', () => {
|
|
148
|
+
expect<bool>(Cookie.create('ok', 'v').validate().valid).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a name that is not a token', () => {
|
|
152
|
+
expect<bool>(Cookie.create('bad name', 'v').validate().valid).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects an empty name', () => {
|
|
156
|
+
expect<bool>(Cookie.create('', 'v').validate().valid).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects a Path that does not start with /', () => {
|
|
160
|
+
expect<bool>(Cookie.create('a', 'b').path('nope').validate().valid).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects name+value over 4096 bytes', () => {
|
|
164
|
+
expect<bool>(Cookie.create('a', 'x'.repeat(5000)).validate().valid).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects a __Host- name without its required attributes', () => {
|
|
168
|
+
const v = new Cookie('__Host-x', 'v').validate();
|
|
169
|
+
expect<bool>(v.valid).toBe(false);
|
|
170
|
+
expect<bool>(v.errors.length > 0).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('accepts a correctly-formed __Host- cookie', () => {
|
|
174
|
+
expect<bool>(Cookie.create('x', 'v').asHostPrefixed().validate().valid).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('flags a Max-Age beyond the 400-day cap', () => {
|
|
178
|
+
expect<bool>(Cookie.create('a', 'b').maxAge(99999999).validate().valid).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// --- Cookies.parse ----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
describe('Cookies.parse', () => {
|
|
185
|
+
it('parses multiple cookies', () => {
|
|
186
|
+
const m = Cookies.parse('a=1; b=2');
|
|
187
|
+
expect<i32>(m.size).toBe(2);
|
|
188
|
+
expect<string>(m.get('a')!).toStrictEqual('1');
|
|
189
|
+
expect<string>(m.get('b')!).toStrictEqual('2');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('returns null for a missing cookie', () => {
|
|
193
|
+
expect<bool>(Cookies.parse('a=1').get('x') == null).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('keeps everything after the first = in the value', () => {
|
|
197
|
+
expect<string>(Cookies.parse('token=ab=cd').get('token')!).toStrictEqual('ab=cd');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('trims surrounding whitespace around name and value', () => {
|
|
201
|
+
expect<string>(Cookies.parse(' a = 1 ; b=2').get('a')!).toStrictEqual('1');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('strips one layer of surrounding quotes', () => {
|
|
205
|
+
expect<string>(Cookies.parse('a="hello"').get('a')!).toStrictEqual('hello');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('percent-decodes values', () => {
|
|
209
|
+
expect<string>(Cookies.parse('x=hello%20world').get('x')!).toStrictEqual('hello world');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('keeps the first occurrence of a duplicate name', () => {
|
|
213
|
+
expect<string>(Cookies.parse('a=1; a=2').get('a')!).toStrictEqual('1');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('handles an empty header', () => {
|
|
217
|
+
expect<i32>(Cookies.parse('').size).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('handles a valueless cookie', () => {
|
|
221
|
+
const m = Cookies.parse('flag');
|
|
222
|
+
expect<bool>(m.has('flag')).toBe(true);
|
|
223
|
+
expect<string>(m.get('flag')!).toStrictEqual('');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// --- value codec ------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
describe('Cookies value codec', () => {
|
|
230
|
+
it('percent-encodes special characters', () => {
|
|
231
|
+
expect<string>(Cookies.encodeValue('a b&c')).toStrictEqual('a%20b%26c');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('round-trips arbitrary UTF-8', () => {
|
|
235
|
+
const original = 'héllo, world! +/=';
|
|
236
|
+
expect<string>(Cookies.decodeValue(Cookies.encodeValue(original))).toStrictEqual(original);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// --- parseSetCookie ---------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
describe('Cookies.parseSetCookie', () => {
|
|
243
|
+
it('round-trips a serialized cookie', () => {
|
|
244
|
+
const wire = Cookie.create('a', 'hello world')
|
|
245
|
+
.domain('x.com')
|
|
246
|
+
.path('/')
|
|
247
|
+
.secure()
|
|
248
|
+
.httpOnly()
|
|
249
|
+
.sameSite(SameSite.Lax)
|
|
250
|
+
.maxAge(60)
|
|
251
|
+
.serialize();
|
|
252
|
+
expect<string>(Cookies.parseSetCookie(wire).serialize()).toStrictEqual(wire);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('parses individual attributes', () => {
|
|
256
|
+
const c = Cookies.parseSetCookie('sid=abc; Path=/; HttpOnly; SameSite=Strict');
|
|
257
|
+
expect<string>(c.name).toStrictEqual('sid');
|
|
258
|
+
expect<string>(c.value).toStrictEqual('abc');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// --- Request / Response integration -----------------------------------------
|
|
263
|
+
|
|
264
|
+
describe('Request cookies', () => {
|
|
265
|
+
it('reads a cookie by name', () => {
|
|
266
|
+
expect<string>(reqWithCookie('a=1; b=2').cookie('a')!).toStrictEqual('1');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns null for a missing cookie', () => {
|
|
270
|
+
expect<bool>(reqWithCookie('a=1').cookie('missing') == null).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('exposes the full jar', () => {
|
|
274
|
+
expect<i32>(reqWithCookie('a=1; b=2; c=3').cookies().size).toBe(3);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('Response cookies', () => {
|
|
279
|
+
it('adds a Set-Cookie header', () => {
|
|
280
|
+
const r = Response.text('x').setCookie(Cookie.create('a', 'b'));
|
|
281
|
+
expect<string>(headerValue(r, 'set-cookie')).toStrictEqual('a=b');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('emits one Set-Cookie header per cookie (never folded)', () => {
|
|
285
|
+
const r = Response.empty(200)
|
|
286
|
+
.setCookie(Cookie.create('a', 'b'))
|
|
287
|
+
.setCookie(Cookie.create('c', 'd'));
|
|
288
|
+
expect<i32>(headerCount(r, 'set-cookie')).toBe(2);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('setCookieKV is a shorthand', () => {
|
|
292
|
+
const r = Response.empty(200).setCookieKV('a', 'b');
|
|
293
|
+
expect<string>(headerValue(r, 'set-cookie')).toStrictEqual('a=b');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('clearCookie emits an expired cookie', () => {
|
|
297
|
+
const r = Response.empty(200).clearCookie('a');
|
|
298
|
+
expect<string>(headerValue(r, 'set-cookie')).toStrictEqual(
|
|
299
|
+
'a=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0',
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// Imports the specific modules rather than the runtime index: the index
|
|
2
|
+
// re-exports `SecureCookies`, which depends on the toilscript crypto std the
|
|
3
|
+
// as-pect compiler does not ship (see test/assembly/cookie.spec.ts).
|
|
4
|
+
import { Method } from '../../server/runtime/request';
|
|
5
|
+
import { Response } from '../../server/runtime/response';
|
|
2
6
|
|
|
3
7
|
describe('server runtime', () => {
|
|
4
8
|
it('numbers the HTTP methods per the wire contract', () => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Imports specific SSR modules (not the runtime index, which pulls in the
|
|
2
|
+
// crypto std the as-pect compiler does not ship). These modules are pure
|
|
3
|
+
// (escaping + buffer building + linear-memory encode), so they run under
|
|
4
|
+
// as-pect; the full `render` export is exercised via the example wasm in
|
|
5
|
+
// test/devserver.test.ts.
|
|
6
|
+
import { escapeHtml, escapeJsonForScript } from '../../server/runtime/ssr/escape';
|
|
7
|
+
import { HASH_LEN, HtmlBuilder, SlotKind, SlotValues } from '../../server/runtime/ssr/slots';
|
|
8
|
+
import { encodeValues } from '../../server/runtime/ssr/encode';
|
|
9
|
+
|
|
10
|
+
function bytesToStr(b: Uint8Array): string {
|
|
11
|
+
return String.UTF8.decodeUnsafe(changetype<usize>(b.dataStart), b.length);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('ssr escape (react-dom byte-identity)', () => {
|
|
15
|
+
it('passes clean text through unchanged', () => {
|
|
16
|
+
expect<string>(escapeHtml('hello world')).toStrictEqual('hello world');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('escapes the five React characters with React entities', () => {
|
|
20
|
+
// React uses ' for the apostrophe and " for the quote.
|
|
21
|
+
expect<string>(escapeHtml('<a href="x">A&B\'s</a>')).toStrictEqual(
|
|
22
|
+
'<a href="x">A&B's</a>',
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('escapes ampersand first-class (not double-encoding)', () => {
|
|
27
|
+
expect<string>(escapeHtml('a & b')).toStrictEqual('a & b');
|
|
28
|
+
expect<string>(escapeHtml('&')).toStrictEqual('&amp;');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('escapes script-context json delimiters', () => {
|
|
32
|
+
expect<string>(escapeJsonForScript('{"x":"</script>"}')).toStrictEqual(
|
|
33
|
+
'{"x":"\\u003c/script\\u003e"}',
|
|
34
|
+
);
|
|
35
|
+
expect<string>(escapeJsonForScript('{"a":1}')).toStrictEqual('{"a":1}');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('ssr SlotValues', () => {
|
|
40
|
+
it('escapes text holes and leaves raw holes verbatim', () => {
|
|
41
|
+
const v = new SlotValues(new StaticArray<u8>(HASH_LEN));
|
|
42
|
+
v.setText(0, '<b>');
|
|
43
|
+
v.setRaw(1, '<b>ok</b>');
|
|
44
|
+
expect<i32>(v.slots.length).toBe(2);
|
|
45
|
+
expect<i32>(<i32>v.slots[0].kind).toBe(<i32>SlotKind.TEXT);
|
|
46
|
+
expect<string>(bytesToStr(v.slots[0].bytes)).toStrictEqual('<b>');
|
|
47
|
+
expect<i32>(<i32>v.slots[1].kind).toBe(<i32>SlotKind.RAW);
|
|
48
|
+
expect<string>(bytesToStr(v.slots[1].bytes)).toStrictEqual('<b>ok</b>');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('stamps repeat rows by interleaving raw chunks and escaped values', () => {
|
|
52
|
+
const v = new SlotValues(new StaticArray<u8>(HASH_LEN));
|
|
53
|
+
const rows = new HtmlBuilder();
|
|
54
|
+
const items = ['a&b', 'c'];
|
|
55
|
+
for (let i = 0; i < items.length; i++) {
|
|
56
|
+
rows.raw('<li>').text(items[i]).raw('</li>');
|
|
57
|
+
}
|
|
58
|
+
v.setRepeat(2, rows);
|
|
59
|
+
expect<i32>(<i32>v.slots[0].kind).toBe(<i32>SlotKind.REPEAT);
|
|
60
|
+
expect<string>(bytesToStr(v.slots[0].bytes)).toStrictEqual(
|
|
61
|
+
'<li>a&b</li><li>c</li>',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('ssr encodeValues wire format', () => {
|
|
67
|
+
it('round-trips status, hash, and one text slot', () => {
|
|
68
|
+
const hash = new StaticArray<u8>(HASH_LEN);
|
|
69
|
+
hash[0] = 0xaa;
|
|
70
|
+
hash[31] = 0xbb;
|
|
71
|
+
const v = new SlotValues(hash);
|
|
72
|
+
v.setStatus(200);
|
|
73
|
+
v.setText(7, 'hi');
|
|
74
|
+
|
|
75
|
+
const buf = new Uint8Array(128);
|
|
76
|
+
const base = changetype<usize>(buf.dataStart);
|
|
77
|
+
const n = encodeValues(v, base);
|
|
78
|
+
// status(2) + hash(32) + n_headers(2) + n_slots(2) + slot[id(2)+kind(1)+len(4)+"hi"(2)]
|
|
79
|
+
expect<i32>(<i32>n).toBe(2 + 32 + 2 + 2 + 2 + 1 + 4 + 2);
|
|
80
|
+
|
|
81
|
+
expect<i32>(<i32>load<u16>(base)).toBe(200); // status
|
|
82
|
+
expect<i32>(<i32>load<u8>(base + 2)).toBe(0xaa); // hash[0]
|
|
83
|
+
expect<i32>(<i32>load<u8>(base + 2 + 31)).toBe(0xbb); // hash[31]
|
|
84
|
+
const afterHash = base + 2 + 32;
|
|
85
|
+
expect<i32>(<i32>load<u16>(afterHash)).toBe(0); // n_headers
|
|
86
|
+
expect<i32>(<i32>load<u16>(afterHash + 2)).toBe(1); // n_slots
|
|
87
|
+
const slot = afterHash + 4;
|
|
88
|
+
expect<i32>(<i32>load<u16>(slot)).toBe(7); // slot_id
|
|
89
|
+
expect<i32>(<i32>load<u8>(slot + 2)).toBe(<i32>SlotKind.TEXT); // kind
|
|
90
|
+
expect<i32>(<i32>load<u32>(slot + 3)).toBe(2); // value_len
|
|
91
|
+
expect<i32>(<i32>load<u8>(slot + 7)).toBe(0x68); // 'h'
|
|
92
|
+
expect<i32>(<i32>load<u8>(slot + 8)).toBe(0x69); // 'i'
|
|
93
|
+
});
|
|
94
|
+
});
|
package/test/devserver.test.ts
CHANGED
|
@@ -155,10 +155,10 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it('serves a plain route', () => {
|
|
158
|
-
const r = get(load(), '/');
|
|
158
|
+
const r = get(load(), '/json');
|
|
159
159
|
expect(r.status).toBe(200);
|
|
160
160
|
expect(r.unhandled).toBe(false);
|
|
161
|
-
expect(Buffer.from(r.body).toString()).toBe('hello
|
|
161
|
+
expect(Buffer.from(r.body).toString()).toBe('{"hello":"toiljs"}\n');
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
it('serves a @rest route with its content-type', () => {
|
|
@@ -186,8 +186,10 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
|
|
|
186
186
|
body: new TextEncoder().encode('{"name":"ada"}'),
|
|
187
187
|
});
|
|
188
188
|
expect(r.unhandled).toBe(false);
|
|
189
|
-
expect(r.status).
|
|
190
|
-
|
|
189
|
+
expect(r.status).toBe(200);
|
|
190
|
+
// u256 id and i64 score cross JSON as decimal strings, exact at any size (3 seeded
|
|
191
|
+
// players, so the new player's id is 4).
|
|
192
|
+
expect(Buffer.from(r.body).toString()).toBe('{"id":"4","name":"ada","score":"0"}');
|
|
191
193
|
});
|
|
192
194
|
|
|
193
195
|
it('keeps requests isolated across instances (fresh state per dispatch)', () => {
|
|
@@ -196,4 +198,46 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
|
|
|
196
198
|
const b = get(m, '/json');
|
|
197
199
|
expect(Buffer.from(a.body).toString()).toBe(Buffer.from(b.body).toString());
|
|
198
200
|
});
|
|
201
|
+
|
|
202
|
+
// Exercises `SecureCookies` (HMAC-SHA256) end-to-end through the real
|
|
203
|
+
// toilscript-compiled wasm with the Node-backed `env.crypto.*` host imports:
|
|
204
|
+
// `/api/cookies/set` seals a signed `__Host-session`, `/api/cookies/inspect`
|
|
205
|
+
// parses + verifies it. (as-pect cannot compile the crypto std, so this is its coverage.)
|
|
206
|
+
const sessionCookie = (m: WasmServerModule): string => {
|
|
207
|
+
const res = get(m, '/api/cookies/set');
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
const pair = res.headers
|
|
210
|
+
.filter(([n]) => n === 'set-cookie')
|
|
211
|
+
.map(([, v]) => v.split(';')[0])
|
|
212
|
+
.find((v) => v.startsWith('__Host-session='));
|
|
213
|
+
expect(pair).toBeDefined();
|
|
214
|
+
return pair!; // "__Host-session=<sealed>"
|
|
215
|
+
};
|
|
216
|
+
const inspect = (m: WasmServerModule, cookie: string): string =>
|
|
217
|
+
Buffer.from(
|
|
218
|
+
m.dispatch({
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: '/api/cookies/inspect',
|
|
221
|
+
headers: [
|
|
222
|
+
['host', 'localhost:3000'],
|
|
223
|
+
['cookie', cookie],
|
|
224
|
+
],
|
|
225
|
+
body: new Uint8Array(0),
|
|
226
|
+
}).body,
|
|
227
|
+
).toString();
|
|
228
|
+
|
|
229
|
+
it('round-trips a signed cookie (SecureCookies sign -> unsign)', () => {
|
|
230
|
+
const m = load();
|
|
231
|
+
expect(inspect(m, sessionCookie(m))).toContain('"session":"user-42"');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects a tampered signed cookie', () => {
|
|
235
|
+
const m = load();
|
|
236
|
+
const pair = sessionCookie(m);
|
|
237
|
+
const eq = pair.indexOf('=');
|
|
238
|
+
const name = pair.slice(0, eq);
|
|
239
|
+
const val = pair.slice(eq + 1);
|
|
240
|
+
const tampered = `${name}=${val[0] === 'A' ? 'B' : 'A'}${val.slice(1)}`;
|
|
241
|
+
expect(inspect(m, tampered)).toContain('"session":null');
|
|
242
|
+
});
|
|
199
243
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Fixture for the generated-client JSON wire-format test (test/rpc-bignum-wire.test.ts).
|
|
2
|
+
// Compiled by the installed toilscript with --rpcModule, then the generated TS client is
|
|
3
|
+
// imported and exercised. Covers every JSON bignum width plus a nested @data so the
|
|
4
|
+
// recursive toJSONValue path is hit.
|
|
5
|
+
|
|
6
|
+
@data
|
|
7
|
+
class Wallet {
|
|
8
|
+
u: u64 = 0;
|
|
9
|
+
i: i64 = 0;
|
|
10
|
+
a: u128 = u128.Zero;
|
|
11
|
+
b: i128 = i128.Zero;
|
|
12
|
+
c: u256 = u256.Zero;
|
|
13
|
+
d: i256 = i256.Zero;
|
|
14
|
+
label: string = '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@data
|
|
18
|
+
class Account {
|
|
19
|
+
main: Wallet = new Wallet();
|
|
20
|
+
ids: u256[] = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A free @remote so buildServerModule emits a surface (it returns null otherwise).
|
|
24
|
+
@remote
|
|
25
|
+
function touch(n: i32): i32 {
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the bignum JSON wire format. 64-bit-and-up integers
|
|
3
|
+
* (u64/i64/u128/i128/u256/i256) must cross JSON as DECIMAL STRINGS, not number tokens
|
|
4
|
+
* or limb arrays: JSON numbers ride through a browser client's JSON.parse as IEEE
|
|
5
|
+
* doubles, which silently corrupt any integer past 2^53.
|
|
6
|
+
*
|
|
7
|
+
* Compiles test/fixtures/bignum-wire/spec.ts with the installed toilscript (so it
|
|
8
|
+
* exercises the published compiler + generated client, not a hand-written stub), then
|
|
9
|
+
* imports the generated TS client and asserts the wire shape both directions, including
|
|
10
|
+
* values far above 2^53 and the legacy limb-array shape older servers emitted.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const spec = path.join(here, 'fixtures', 'bignum-wire', 'spec.ts');
|
|
22
|
+
// The generated module imports DataWriter/DataReader from this specifier.
|
|
23
|
+
const codec = path.join(here, '..', 'src', 'io', 'codec.ts');
|
|
24
|
+
|
|
25
|
+
/** Resolves the installed toilscript CLI entry (no PATH / .bin assumptions). */
|
|
26
|
+
function toilscriptBin(): string {
|
|
27
|
+
const pkgPath = require.resolve('toilscript/package.json');
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin?: Record<string, string> };
|
|
29
|
+
const binRel = pkg.bin?.toilscript;
|
|
30
|
+
if (!binRel) throw new Error('toilscript declares no bin');
|
|
31
|
+
return path.join(path.dirname(pkgPath), binRel);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Wallet {
|
|
35
|
+
u: bigint;
|
|
36
|
+
i: bigint;
|
|
37
|
+
a: bigint;
|
|
38
|
+
b: bigint;
|
|
39
|
+
c: bigint;
|
|
40
|
+
d: bigint;
|
|
41
|
+
label: string;
|
|
42
|
+
toJSONValue(): Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
interface WalletStatic {
|
|
45
|
+
new (): Wallet;
|
|
46
|
+
fromJSONValue(v: unknown): Wallet;
|
|
47
|
+
}
|
|
48
|
+
interface AccountStatic {
|
|
49
|
+
new (): { main: Wallet; ids: bigint[]; toJSONValue(): Record<string, unknown> };
|
|
50
|
+
fromJSONValue(v: unknown): { main: Wallet; ids: bigint[] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let Wallet: WalletStatic;
|
|
54
|
+
let Account: AccountStatic;
|
|
55
|
+
let tmp: string;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bignum-wire-'));
|
|
59
|
+
fs.writeFileSync(path.join(tmp, 'package.json'), '{ "type": "module" }\n');
|
|
60
|
+
// vitest transforms the imported .ts through Vite/oxc, which walks up for a tsconfig.
|
|
61
|
+
fs.writeFileSync(
|
|
62
|
+
path.join(tmp, 'tsconfig.json'),
|
|
63
|
+
JSON.stringify({ compilerOptions: { target: 'esnext', module: 'esnext' } }),
|
|
64
|
+
);
|
|
65
|
+
const mod = path.join(tmp, 'server.ts');
|
|
66
|
+
const wasm = path.join(tmp, 'spec.wasm');
|
|
67
|
+
const res = spawnSync(
|
|
68
|
+
process.execPath,
|
|
69
|
+
[
|
|
70
|
+
toilscriptBin(),
|
|
71
|
+
spec,
|
|
72
|
+
'-o',
|
|
73
|
+
wasm,
|
|
74
|
+
'--runtime',
|
|
75
|
+
'stub',
|
|
76
|
+
'--initialMemory',
|
|
77
|
+
'32',
|
|
78
|
+
'--rpcModule',
|
|
79
|
+
mod,
|
|
80
|
+
'--rpcRuntime',
|
|
81
|
+
codec,
|
|
82
|
+
],
|
|
83
|
+
{ encoding: 'utf8' },
|
|
84
|
+
);
|
|
85
|
+
if (res.status !== 0) throw new Error('toilscript compile failed:\n' + res.stderr);
|
|
86
|
+
const gen = (await import(pathToFileURL(mod).href)) as {
|
|
87
|
+
Wallet: WalletStatic;
|
|
88
|
+
Account: AccountStatic;
|
|
89
|
+
};
|
|
90
|
+
Wallet = gen.Wallet;
|
|
91
|
+
Account = gen.Account;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// A few representative bignum types; `huge` is well past Number.MAX_SAFE_INTEGER.
|
|
95
|
+
const huge = '123456789012345678901234567890';
|
|
96
|
+
const u128Max = '340282366920938463463374607431768211455';
|
|
97
|
+
const i64Min = '-9223372036854775808';
|
|
98
|
+
|
|
99
|
+
describe('generated client bignum JSON wire format', () => {
|
|
100
|
+
it('serializes every 64-bit-and-up integer as a decimal string', () => {
|
|
101
|
+
const w = new Wallet();
|
|
102
|
+
w.u = BigInt(huge.slice(0, 19)); // fits u64
|
|
103
|
+
w.i = BigInt(i64Min);
|
|
104
|
+
w.a = BigInt(u128Max);
|
|
105
|
+
w.b = -123n;
|
|
106
|
+
w.c = BigInt(huge);
|
|
107
|
+
w.d = -1n;
|
|
108
|
+
w.label = 'x';
|
|
109
|
+
const json = w.toJSONValue();
|
|
110
|
+
// Each bignum field is a string, never a number or an array.
|
|
111
|
+
for (const k of ['u', 'i', 'a', 'b', 'c', 'd'] as const) {
|
|
112
|
+
expect(typeof json[k], `field ${k}`).toBe('string');
|
|
113
|
+
}
|
|
114
|
+
expect(json.a).toBe(u128Max);
|
|
115
|
+
expect(json.c).toBe(huge);
|
|
116
|
+
expect(json.i).toBe(i64Min);
|
|
117
|
+
expect(json.label).toBe('x');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('revives a decimal string past 2^53 into an exact bigint', () => {
|
|
121
|
+
const w = Wallet.fromJSONValue({
|
|
122
|
+
u: '0',
|
|
123
|
+
i: i64Min,
|
|
124
|
+
a: u128Max,
|
|
125
|
+
b: '-123',
|
|
126
|
+
c: huge,
|
|
127
|
+
d: '-1',
|
|
128
|
+
label: 'y',
|
|
129
|
+
});
|
|
130
|
+
expect(w.c).toBe(BigInt(huge));
|
|
131
|
+
expect(w.a).toBe(BigInt(u128Max));
|
|
132
|
+
expect(w.i).toBe(BigInt(i64Min));
|
|
133
|
+
expect(w.d).toBe(-1n);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('round-trips a value through send then revive without loss', () => {
|
|
137
|
+
const w = new Wallet();
|
|
138
|
+
w.c = BigInt(huge);
|
|
139
|
+
w.d = BigInt('-' + huge);
|
|
140
|
+
const back = Wallet.fromJSONValue(JSON.parse(JSON.stringify(w.toJSONValue())));
|
|
141
|
+
expect(back.c).toBe(BigInt(huge));
|
|
142
|
+
expect(back.d).toBe(BigInt('-' + huge));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('still revives the legacy little-endian limb-array shape (back-compat)', () => {
|
|
146
|
+
// u256 [5,0,4,0] little-endian = 5 + 4*2^128.
|
|
147
|
+
const w = Wallet.fromJSONValue({ c: [5, 0, 4, 0], a: [9, 1] });
|
|
148
|
+
expect(w.c).toBe(2n ** 130n + 5n);
|
|
149
|
+
expect(w.a).toBe(2n ** 64n + 9n);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('recurses into nested @data and arrays of bignums', () => {
|
|
153
|
+
const a = new Account();
|
|
154
|
+
a.main.c = BigInt(huge);
|
|
155
|
+
a.ids = [1n, BigInt(huge)];
|
|
156
|
+
const json = a.toJSONValue();
|
|
157
|
+
const main = json.main as Record<string, unknown>;
|
|
158
|
+
expect(main.c).toBe(huge);
|
|
159
|
+
expect(json.ids).toEqual(['1', huge]);
|
|
160
|
+
const back = Account.fromJSONValue(JSON.parse(JSON.stringify(json)));
|
|
161
|
+
expect(back.main.c).toBe(BigInt(huge));
|
|
162
|
+
expect(back.ids).toEqual([1n, BigInt(huge)]);
|
|
163
|
+
});
|
|
164
|
+
});
|