toiljs 0.0.33 → 0.0.34
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 +9 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +27 -7
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.d.ts +8 -0
- package/build/devserver/cache.js +0 -0
- package/build/devserver/index.js +10 -1
- package/examples/basic/server/README.md +19 -0
- package/examples/basic/server/{HelloHandler.ts → core/AppHandler.ts} +9 -4
- package/examples/basic/server/core/store.ts +31 -0
- package/examples/basic/server/main.ts +11 -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/Leaderboard.ts +20 -0
- package/examples/basic/server/routes/Players.ts +53 -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/package.json +2 -2
- package/server/runtime/response.ts +56 -0
- package/src/cli/create.ts +54 -14
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/index.ts +21 -1
- package/test/devserver.test.ts +6 -4
- package/test/fixtures/bignum-wire/spec.ts +27 -0
- package/test/rpc-bignum-wire.test.ts +164 -0
- package/examples/basic/server/api.ts +0 -137
|
@@ -106,4 +106,60 @@ export class Response {
|
|
|
106
106
|
|
|
107
107
|
return this;
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Mark this response cacheable at the toil edge and/or the browser.
|
|
112
|
+
*
|
|
113
|
+
* `edgeTtlMinutes` caches the response on the edge node (per-core,
|
|
114
|
+
* keyed by host + method + path + request body) for up to that many
|
|
115
|
+
* minutes -- the ONLY way a POST response is cached. `browserTtlSeconds`
|
|
116
|
+
* emits a `Cache-Control: max-age` for the client.
|
|
117
|
+
*
|
|
118
|
+
* Host-side safety, enforced no matter what you ask for: the edge TTL
|
|
119
|
+
* is clamped to 24h, only 2xx responses are edge-cached, a response
|
|
120
|
+
* carrying a `Set-Cookie` is never edge-cached, and an AUTHENTICATED
|
|
121
|
+
* request (one with a `Cookie` or `Authorization` header) is not
|
|
122
|
+
* edge-cached unless you pass `allowAuth = true`. Only mark a response
|
|
123
|
+
* cacheable when its body is a pure function of (host, path, body) --
|
|
124
|
+
* never per-user data keyed by a cookie/header that is not in the path
|
|
125
|
+
* or body, or one user's response could be served to another.
|
|
126
|
+
*
|
|
127
|
+
* Builder-style: returns `this`.
|
|
128
|
+
*/
|
|
129
|
+
public cache(
|
|
130
|
+
edgeTtlMinutes: u16,
|
|
131
|
+
browserTtlSeconds: u32 = 0,
|
|
132
|
+
privateScope: bool = false,
|
|
133
|
+
allowAuth: bool = false,
|
|
134
|
+
): Response {
|
|
135
|
+
let v = '';
|
|
136
|
+
if (edgeTtlMinutes > 0) {
|
|
137
|
+
v = 'edge=' + edgeTtlMinutes.toString();
|
|
138
|
+
}
|
|
139
|
+
if (browserTtlSeconds > 0) {
|
|
140
|
+
if (v.length > 0) v += '; ';
|
|
141
|
+
v += 'browser=' + browserTtlSeconds.toString();
|
|
142
|
+
}
|
|
143
|
+
if (privateScope) {
|
|
144
|
+
if (v.length > 0) v += '; ';
|
|
145
|
+
v += 'scope=private';
|
|
146
|
+
}
|
|
147
|
+
if (allowAuth) {
|
|
148
|
+
if (v.length > 0) v += '; ';
|
|
149
|
+
v += 'auth=1';
|
|
150
|
+
}
|
|
151
|
+
if (v.length > 0) {
|
|
152
|
+
this.setHeader('toil-cache-control', v);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Shorthand for {@link cache}: edge-cache this response for `minutes`
|
|
160
|
+
* minutes (no browser caching).
|
|
161
|
+
*/
|
|
162
|
+
public cacheFor(minutes: u16): Response {
|
|
163
|
+
return this.cache(minutes);
|
|
164
|
+
}
|
|
109
165
|
}
|
package/src/cli/create.ts
CHANGED
|
@@ -175,12 +175,10 @@ function scaffold(
|
|
|
175
175
|
'toilconfig.json':
|
|
176
176
|
JSON.stringify(
|
|
177
177
|
{
|
|
178
|
-
// `toiljs build` compiles every server
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
? ['server/main.ts']
|
|
183
|
-
: ['server/main.ts', 'server/api.ts'],
|
|
178
|
+
// `toiljs build` compiles every decorated server file (recursively) so
|
|
179
|
+
// dropped-in @data/@rest files are picked up; main.ts imports the surface
|
|
180
|
+
// modules so a direct `toilscript` run builds the same server.
|
|
181
|
+
entries: ['server/main.ts'],
|
|
184
182
|
targets: {
|
|
185
183
|
release: {
|
|
186
184
|
outFile: 'build/server/release.wasm',
|
|
@@ -261,12 +259,18 @@ function scaffold(
|
|
|
261
259
|
return files;
|
|
262
260
|
}
|
|
263
261
|
|
|
264
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* A minimal but working server for the `minimal` template (the `app` template copies
|
|
264
|
+
* examples/basic/server). Same folder conventions as the full starter, just fewer files:
|
|
265
|
+
* the entry in main.ts, the handler under core/, and a README mapping where new
|
|
266
|
+
* routes/services/models go.
|
|
267
|
+
*/
|
|
265
268
|
function minimalServer(): Record<string, string> {
|
|
266
269
|
return {
|
|
267
|
-
'server/
|
|
270
|
+
'server/core/AppHandler.ts':
|
|
268
271
|
"import { ToilHandler, Request, Response, Method } from 'toiljs/server/runtime';\n\n" +
|
|
269
|
-
'
|
|
272
|
+
'/** Every request enters here. Add `@rest` controllers under routes/ as you grow. */\n' +
|
|
273
|
+
'export class AppHandler extends ToilHandler {\n' +
|
|
270
274
|
' public handle(req: Request): Response {\n' +
|
|
271
275
|
' if (req.method != Method.GET && req.method != Method.HEAD) {\n' +
|
|
272
276
|
" return Response.empty(405).setHeader('allow', 'GET, HEAD');\n" +
|
|
@@ -285,15 +289,31 @@ function minimalServer(): Record<string, string> {
|
|
|
285
289
|
'}\n',
|
|
286
290
|
'server/main.ts':
|
|
287
291
|
"import { Server } from 'toiljs/server/runtime';\n" +
|
|
288
|
-
"import { revertOnError } from 'toiljs/server/runtime/abort/abort';\n" +
|
|
289
|
-
"import {
|
|
292
|
+
"import { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\n" +
|
|
293
|
+
"import { AppHandler } from './core/AppHandler';\n\n" +
|
|
294
|
+
'// As you add surface modules (@rest routes, @service/@remote RPC), import them here\n' +
|
|
295
|
+
'// so a direct `toilscript` run builds the same server `toiljs build` does, e.g.:\n' +
|
|
296
|
+
"// import './routes/Players';\n\n" +
|
|
290
297
|
'// Wire your handler here.\n' +
|
|
291
|
-
'Server.handler = () => new
|
|
298
|
+
'Server.handler = () => new AppHandler();\n\n' +
|
|
292
299
|
'// Required: re-export the WASM entry points and the abort hook.\n' +
|
|
293
300
|
"export * from 'toiljs/server/runtime/exports';\n" +
|
|
294
301
|
'export function abort(message: string, fileName: string, line: u32, column: u32): void {\n' +
|
|
295
302
|
' revertOnError(message, fileName, line, column);\n' +
|
|
296
303
|
'}\n',
|
|
304
|
+
'server/README.md':
|
|
305
|
+
'# server/\n\n' +
|
|
306
|
+
'Your ToilScript backend, compiled to a single WebAssembly module. One folder per concern:\n\n' +
|
|
307
|
+
'| Folder | What lives here |\n' +
|
|
308
|
+
'| --- | --- |\n' +
|
|
309
|
+
'| `main.ts` | The entry point: wires the handler and imports the surface modules. |\n' +
|
|
310
|
+
'| `core/` | The request handler and shared app logic (state, helpers). |\n' +
|
|
311
|
+
'| `models/` | `@data` classes, the typed wire model shared by HTTP and RPC. One type per file. |\n' +
|
|
312
|
+
'| `routes/` | `@rest` controllers (HTTP). One controller per file, named after its class. |\n' +
|
|
313
|
+
'| `services/` | `@service` classes and free `@remote` functions (typed RPC). |\n' +
|
|
314
|
+
'| `scheduled/` | Reserved for scheduled tasks (not shipped yet). |\n\n' +
|
|
315
|
+
'New decorated files are picked up automatically by `toiljs build`/`dev`; also add an import\n' +
|
|
316
|
+
'in `main.ts` so a direct `toilscript` run builds the same server.\n',
|
|
297
317
|
};
|
|
298
318
|
}
|
|
299
319
|
|
|
@@ -586,7 +606,27 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
|
586
606
|
if (template === 'app') {
|
|
587
607
|
// Copy the example client + server (the single starter source), set the <title>, then style.
|
|
588
608
|
await fs.cp(appClientDir(), path.join(targetDir, 'client'), { recursive: true });
|
|
589
|
-
|
|
609
|
+
// Only the canonical starter layout ships; anything else sitting in the example's
|
|
610
|
+
// server/ (local experiments, scratch entries) stays out of scaffolded apps.
|
|
611
|
+
const serverAllow = new Set([
|
|
612
|
+
'main.ts',
|
|
613
|
+
'README.md',
|
|
614
|
+
'tsconfig.json',
|
|
615
|
+
'core',
|
|
616
|
+
'models',
|
|
617
|
+
'routes',
|
|
618
|
+
'services',
|
|
619
|
+
'scheduled',
|
|
620
|
+
]);
|
|
621
|
+
const serverSrc = appServerDir();
|
|
622
|
+
await fs.cp(serverSrc, path.join(targetDir, 'server'), {
|
|
623
|
+
recursive: true,
|
|
624
|
+
filter: (src) => {
|
|
625
|
+
const rel = path.relative(serverSrc, src);
|
|
626
|
+
if (rel === '') return true;
|
|
627
|
+
return serverAllow.has(rel.split(path.sep)[0]);
|
|
628
|
+
},
|
|
629
|
+
});
|
|
590
630
|
const indexHtml = path.join(targetDir, 'client', 'public', 'index.html');
|
|
591
631
|
const html = await fs.readFile(indexHtml, 'utf8');
|
|
592
632
|
await fs.writeFile(
|
|
@@ -628,5 +668,5 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
|
|
|
628
668
|
steps.push(`${accent('npm run build')} ${dim('build for production')}`);
|
|
629
669
|
note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
|
|
630
670
|
|
|
631
|
-
outro(`Created ${accent(path.basename(name))}, happy building! ${dim('
|
|
671
|
+
outro(`Created ${accent(path.basename(name))}, happy building! ${dim('(v' + version() + ')')}`);
|
|
632
672
|
}
|
|
Binary file
|
package/src/devserver/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
import { Server, type Request, type Response } from '@dacely/hyper-express';
|
|
26
26
|
import pc from 'picocolors';
|
|
27
27
|
|
|
28
|
+
import { applyCacheRule, lookupCache } from './cache.js';
|
|
28
29
|
import { METHOD_CODES, type EnvelopeRequest } from './envelope.js';
|
|
29
30
|
import { WasmServerModule } from './module.js';
|
|
30
31
|
import { proxyToVite, wireWebsocketProxy, type ViteTarget } from './proxy.js';
|
|
@@ -210,10 +211,29 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
|
|
|
210
211
|
|
|
211
212
|
if (dispatchable && module.available) {
|
|
212
213
|
const envelopeReq = await toEnvelopeRequest(request);
|
|
214
|
+
// Honor the tenant cache directive locally, same rules as the
|
|
215
|
+
// edge: serve an identical request from the per-process cache,
|
|
216
|
+
// else dispatch and apply/strip the directive on the response.
|
|
217
|
+
const cacheHost = request.headers.host ?? 'dev';
|
|
218
|
+
const hasAuth =
|
|
219
|
+
request.headers.cookie !== undefined || request.headers.authorization !== undefined;
|
|
220
|
+
const cached = lookupCache(cacheHost, request.method, request.url, envelopeReq.body);
|
|
221
|
+
if (cached !== null) {
|
|
222
|
+
sendWasmResponse(response, root, cached);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
213
225
|
try {
|
|
214
226
|
const result = module.dispatch(envelopeReq);
|
|
215
227
|
if (!result.unhandled) {
|
|
216
|
-
|
|
228
|
+
const finalized = applyCacheRule(
|
|
229
|
+
cacheHost,
|
|
230
|
+
request.method,
|
|
231
|
+
request.url,
|
|
232
|
+
envelopeReq.body,
|
|
233
|
+
hasAuth,
|
|
234
|
+
result,
|
|
235
|
+
);
|
|
236
|
+
sendWasmResponse(response, root, finalized);
|
|
217
237
|
return;
|
|
218
238
|
}
|
|
219
239
|
} catch (e) {
|
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)', () => {
|
|
@@ -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
|
+
});
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// A small REST demo: a players list + a leaderboard.
|
|
2
|
-
//
|
|
3
|
-
// IMPORTANT - the server runs with a FRESH WebAssembly instance per request, so linear memory
|
|
4
|
-
// (and any module-level state, like the `store` below) is reset on every request. It does NOT
|
|
5
|
-
// persist across requests. We seed a few players at module init so the read routes always have
|
|
6
|
-
// data; the write routes (create / addScore) take effect only for the current request's response.
|
|
7
|
-
// For real persistence, call out to a database or KV store from your handler.
|
|
8
|
-
//
|
|
9
|
-
// `@data` types are the wire model (shared by HTTP and RPC). `@rest` controllers expose HTTP
|
|
10
|
-
// routes; `@service`/`@remote` expose typed RPC. Building the server (toilscript --rpcModule)
|
|
11
|
-
// regenerates the typed client into shared/server.ts:
|
|
12
|
-
// @rest -> Server.REST.<controller>.<route>(args) (a working fetch client)
|
|
13
|
-
// @service -> Server.<service>.<method>() (RPC, transport TODO)
|
|
14
|
-
|
|
15
|
-
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
16
|
-
|
|
17
|
-
/** A leaderboard player. */
|
|
18
|
-
@data
|
|
19
|
-
class Player {
|
|
20
|
-
id: u64 = 0;
|
|
21
|
-
name: string = '';
|
|
22
|
-
score: i64 = 0;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Request body for `POST /players` - the fields a client supplies to create a player. */
|
|
26
|
-
@data
|
|
27
|
-
class NewPlayer {
|
|
28
|
-
name: string = '';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Request body for `POST /players/:id/score` - points to add to a player's score. */
|
|
32
|
-
@data
|
|
33
|
-
class ScoreDelta {
|
|
34
|
-
points: i64 = 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A leaderboard page. A `@data` wrapper so the `Player[]` round-trips through the codec. */
|
|
38
|
-
@data
|
|
39
|
-
class Standings {
|
|
40
|
-
players: Player[] = [];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Re-seeded on EVERY request - module memory does not persist across requests (see the note at
|
|
44
|
-
// the top of the file). Swap this for a database/KV to keep data between requests.
|
|
45
|
-
const store = new Map<u64, Player>();
|
|
46
|
-
let nextId: u64 = 1;
|
|
47
|
-
|
|
48
|
-
function seed(name: string, score: i64): void {
|
|
49
|
-
const p = new Player();
|
|
50
|
-
p.id = nextId++;
|
|
51
|
-
p.name = name;
|
|
52
|
-
p.score = score;
|
|
53
|
-
store.set(p.id, p);
|
|
54
|
-
}
|
|
55
|
-
seed('Ada', 120);
|
|
56
|
-
seed('Linus', 95);
|
|
57
|
-
seed('Grace', 140);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Players, mounted at `/players`. On the client:
|
|
61
|
-
* await Server.REST.players.get({ params: { id } })
|
|
62
|
-
* await Server.REST.players.create({ body: new NewPlayer('Bob') })
|
|
63
|
-
* await Server.REST.players.addScore({ params: { id }, body: new ScoreDelta(10n) })
|
|
64
|
-
*/
|
|
65
|
-
@rest('players')
|
|
66
|
-
class Players {
|
|
67
|
-
/** `GET /players/:id` - returns a `Response` for full control: a real 404 for a missing id,
|
|
68
|
-
* a custom header, and the `@data` body serialized with `toJSON()`. (The toilscript editor
|
|
69
|
-
* plugin types the compiler-injected `toJSON()`, so this is clean; return the `@data` type
|
|
70
|
-
* directly, like the other routes, when you do not need that control.) */
|
|
71
|
-
@get('/:id')
|
|
72
|
-
public get(ctx: RouteContext): Response {
|
|
73
|
-
const id = u64.parse(ctx.param('id'));
|
|
74
|
-
if (!store.has(id)) return Response.notFound();
|
|
75
|
-
const p = store.get(id);
|
|
76
|
-
|
|
77
|
-
return Response.json(p.toJSON().toString()).setHeader('cache-control', 'no-store');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** `POST /players` - build a player from the request body and return it with a fresh id.
|
|
81
|
-
* Note: it is NOT saved (memory resets next request); persist to a real store to keep it. */
|
|
82
|
-
@post('/')
|
|
83
|
-
public create(input: NewPlayer): Player {
|
|
84
|
-
const p = new Player();
|
|
85
|
-
p.id = nextId++;
|
|
86
|
-
p.name = input.name;
|
|
87
|
-
p.score = 0;
|
|
88
|
-
store.set(p.id, p);
|
|
89
|
-
|
|
90
|
-
return p;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** `POST /players/:id/score` - add `points` (from the body) to the seeded player named by
|
|
94
|
-
* `:id` and return it. The change applies to this response only (memory resets next request). */
|
|
95
|
-
@post('/:id/score')
|
|
96
|
-
public addScore(input: ScoreDelta, ctx: RouteContext): Player {
|
|
97
|
-
const id = u64.parse(ctx.param('id'));
|
|
98
|
-
if (!store.has(id)) return new Player();
|
|
99
|
-
const p = store.get(id);
|
|
100
|
-
p.score += input.points;
|
|
101
|
-
|
|
102
|
-
return p;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The leaderboard, mounted at `/leaderboard`. On the client:
|
|
108
|
-
* const board = await Server.REST.leaderboard.top(); // typed Standings { players: Player[] }
|
|
109
|
-
*/
|
|
110
|
-
@rest('leaderboard')
|
|
111
|
-
class Leaderboard {
|
|
112
|
-
/** `GET /leaderboard` - the seeded players, highest score first. */
|
|
113
|
-
@get('/')
|
|
114
|
-
public top(): Standings {
|
|
115
|
-
const board = new Standings();
|
|
116
|
-
const all = store.values();
|
|
117
|
-
for (let i = 0; i < all.length; i++) board.players.push(all[i]);
|
|
118
|
-
board.players.sort((a: Player, b: Player): i32 => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0));
|
|
119
|
-
return board;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
|
|
124
|
-
@service
|
|
125
|
-
class Stats {
|
|
126
|
-
/** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
|
|
127
|
-
@remote
|
|
128
|
-
public playerCount(): i32 {
|
|
129
|
-
return store.size;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** A free `@remote` function: `Server.ping(n)` on the client. */
|
|
134
|
-
@remote
|
|
135
|
-
function ping(n: i32): i32 {
|
|
136
|
-
return n + 1;
|
|
137
|
-
}
|