toiljs 0.0.32 → 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.
@@ -0,0 +1,20 @@
1
+ import { store } from '../core/store';
2
+ import { Player } from '../models/Player';
3
+ import { Standings } from '../models/Standings';
4
+
5
+ /**
6
+ * The leaderboard, mounted at `/leaderboard`. On the client:
7
+ * const board = await Server.REST.leaderboard.top(); // typed Standings { players: Player[] }
8
+ */
9
+ @rest('leaderboard')
10
+ class Leaderboard {
11
+ /** `GET /leaderboard` - the seeded players, highest score first. */
12
+ @get('/')
13
+ public top(): Standings {
14
+ const board = new Standings();
15
+ const all = store.values();
16
+ for (let i = 0; i < all.length; i++) board.players.push(all[i]);
17
+ board.players.sort((a: Player, b: Player): i32 => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0));
18
+ return board;
19
+ }
20
+ }
@@ -0,0 +1,53 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+
3
+ import { allocId, store } from '../core/store';
4
+ import { NewPlayer } from '../models/NewPlayer';
5
+ import { Player } from '../models/Player';
6
+ import { ScoreDelta } from '../models/ScoreDelta';
7
+
8
+ /**
9
+ * Players, mounted at `/players`. On the client:
10
+ * await Server.REST.players.get({ params: { id } })
11
+ * await Server.REST.players.create({ body: new NewPlayer('Bob') })
12
+ * await Server.REST.players.addScore({ params: { id }, body: new ScoreDelta(10n) })
13
+ */
14
+ @rest('players')
15
+ class Players {
16
+ /** `GET /players/:id` - returns a `Response` for full control: a real 404 for a missing id,
17
+ * a custom header, and the `@data` body serialized with `toJSON()`. (The toilscript editor
18
+ * plugin types the compiler-injected `toJSON()`, so this is clean; return the `@data` type
19
+ * directly, like the other routes, when you do not need that control.) */
20
+ @get('/:id')
21
+ public get(ctx: RouteContext): Response {
22
+ const id = u64.parse(ctx.param('id'));
23
+ if (!store.has(id)) return Response.notFound();
24
+ const p = store.get(id);
25
+
26
+ return Response.json(p.toJSON().toString()).setHeader('cache-control', 'no-store');
27
+ }
28
+
29
+ /** `POST /players` - build a player from the request body and return it with a fresh id.
30
+ * Note: it is NOT saved (memory resets next request); persist to a real store to keep it. */
31
+ @post('/')
32
+ public create(input: NewPlayer): Player {
33
+ const p = new Player();
34
+ p.id = u256.fromU64(allocId());
35
+ p.name = input.name;
36
+ p.score = 0;
37
+ store.set(p.id.toU64(), p);
38
+
39
+ return p;
40
+ }
41
+
42
+ /** `POST /players/:id/score` - add `points` (from the body) to the seeded player named by
43
+ * `:id` and return it. The change applies to this response only (memory resets next request). */
44
+ @post('/:id/score')
45
+ public addScore(input: ScoreDelta, ctx: RouteContext): Player {
46
+ const id = u64.parse(ctx.param('id'));
47
+ if (!store.has(id)) return new Player();
48
+ const p = store.get(id);
49
+ p.score += input.points;
50
+
51
+ return p;
52
+ }
53
+ }
@@ -0,0 +1,7 @@
1
+ # scheduled/
2
+
3
+ Reserved for scheduled tasks: cron-style jobs that will run on the Toil edge runtime.
4
+
5
+ The scheduling API has not shipped yet. The folder convention exists today so your project
6
+ layout will not change when it lands; until then, anything in here is ignored by the build
7
+ (only `.ts` files with a server surface are compiled).
@@ -0,0 +1,11 @@
1
+ import { store } from '../core/store';
2
+
3
+ /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
4
+ @service
5
+ class Stats {
6
+ /** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
7
+ @remote
8
+ public playerCount(): i32 {
9
+ return store.size;
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ /** Free `@remote` functions: callable as `Server.<name>()` on the client. */
2
+
3
+ /** `Server.ping(n)` on the client. */
4
+ @remote
5
+ function ping(n: i32): i32 {
6
+ return n + 1;
7
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.32",
4
+ "version": "0.0.34",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -126,7 +126,7 @@
126
126
  "eslint-plugin-react-refresh": "^0.5.2",
127
127
  "picocolors": "^1.1.1",
128
128
  "sharp": "^0.35.0",
129
- "toilscript": "^0.1.18",
129
+ "toilscript": "^0.1.21",
130
130
  "typescript-eslint": "^8.60.0",
131
131
  "vite": "^8.0.14",
132
132
  "vite-imagetools": "^10.0.0",
@@ -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/*.ts so dropped-in @data/@rest files are
179
- // picked up; these entries are the fallback when running `toilscript` directly.
180
- entries:
181
- template === 'minimal'
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
- /** A minimal but working server for the `minimal` template (the `app` template copies examples/basic/server). */
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/HelloHandler.ts':
270
+ 'server/core/AppHandler.ts':
268
271
  "import { ToilHandler, Request, Response, Method } from 'toiljs/server/runtime';\n\n" +
269
- 'export class HelloHandler extends ToilHandler {\n' +
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 { HelloHandler } from './HelloHandler';\n\n" +
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 HelloHandler();\n\n' +
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
- await fs.cp(appServerDir(), path.join(targetDir, 'server'), { recursive: true });
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('· v' + version())}`);
671
+ outro(`Created ${accent(path.basename(name))}, happy building! ${dim('(v' + version() + ')')}`);
632
672
  }
package/src/cli/notify.ts CHANGED
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
14
  import { detectPackageManager } from './update.js';
15
- import { accent, bold, dim, version as cliVersion, warn } from './ui.js';
15
+ import { accent, bold, box, dim, version as cliVersion, warn } from './ui.js';
16
16
  import {
17
17
  findOutdated,
18
18
  isCacheFresh,
@@ -88,12 +88,12 @@ function projectToiljsVersion(root: string): string | null {
88
88
  }
89
89
 
90
90
  function noticeLines(latest: string, rows: OutdatedRow[]): string {
91
- const header = warn(' ⚠ ') + bold(`a newer toiljs is available: ${accent(latest)}`);
91
+ const header = warn('⚠ ') + bold(`a newer toiljs is available: ${accent(latest)}`);
92
92
  const body = rows.map((row) => {
93
93
  const where = row.scope === 'project' ? 'this project has' : 'your global CLI is';
94
- return ` ${where} ${row.installed}${dim(', update with')} ${accent(row.command)}`;
94
+ return `${where} ${row.installed}${dim(', update with')} ${accent(row.command)}`;
95
95
  });
96
- return '\n' + [header, ...body].join('\n') + '\n';
96
+ return '\n' + box([header, '', ...body], warn) + '\n';
97
97
  }
98
98
 
99
99
  /**
package/src/cli/ui.ts CHANGED
@@ -102,10 +102,69 @@ export function version(): string {
102
102
  return '0.0.0';
103
103
  }
104
104
 
105
- /** Prints the brand banner: gradient logo + tagline + version. */
105
+ // eslint-disable-next-line no-control-regex -- matching our own escape sequences is the point
106
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
107
+
108
+ /** The on-screen width of `s`, ignoring ANSI color codes. */
109
+ function visibleWidth(s: string): number {
110
+ return s.replace(ANSI_RE, '').length;
111
+ }
112
+
113
+ /**
114
+ * Frames already-colored lines in a rounded box sized to the widest line. `paint` colors the
115
+ * border (the content keeps its own colors); padding is measured on the visible text, so ANSI
116
+ * codes inside the lines never skew the right edge. Returns the box without a trailing newline.
117
+ */
118
+ export function box(lines: readonly string[], paint: (s: string) => string = (s) => s): string {
119
+ const width = lines.reduce((w, l) => Math.max(w, visibleWidth(l)), 0);
120
+ const side = paint('│');
121
+ const body = lines.map(
122
+ (l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`,
123
+ );
124
+ return [
125
+ ' ' + paint(`╭${'─'.repeat(width + 4)}╮`),
126
+ ...body,
127
+ ' ' + paint(`╰${'─'.repeat(width + 4)}╯`),
128
+ ].join('\n');
129
+ }
130
+
131
+ /**
132
+ * Banner taglines, one is picked at random per invocation. Each is a function so the accented
133
+ * words pick up the brand color (or stay plain when color is disabled). The theme: TOIL is the
134
+ * first full-stack framework for a globally distributed application delivery network.
135
+ */
136
+ const TAGLINES: ReadonlyArray<(a: (s: string) => string) => string> = [
137
+ (a) => `the most performant ${a('react')} framework`,
138
+ (a) => `bringing ${a('hyper scale')} to anyone`,
139
+ (a) => `the first full-stack ${a('application delivery network')}`,
140
+ (a) => `your app, ${a('globally distributed')} by default`,
141
+ (a) => `one build, ${a('the whole planet')}`,
142
+ (a) => `full stack, ${a('zero distance')} to your users`,
143
+ (a) => `${a('react')} up front, ${a('wasm')} at every edge`,
144
+ (a) => `deployed where your ${a('users')} are`,
145
+ (a) => `the framework with a ${a('delivery network')} built in`,
146
+ (a) => `no regions, just ${a('the world')}`,
147
+ (a) => `${a('planet-scale')} apps from a single repo`,
148
+ (a) => `every request served ${a('next door')}`,
149
+ (a) => `frontend, backend, ${a('worldwide')}`,
150
+ (a) => `${a('hyper scale')} without the ops team`,
151
+ (a) => `your backend, ${a('compiled to wasm')}, running everywhere`,
152
+ (a) => `the internet is your ${a('runtime')}`,
153
+ (a) => `the speed of light is the ${a('only bottleneck')}`,
154
+ (a) => `static speed, ${a('dynamic everything')}`,
155
+ (a) => `scale to ${a('millions')} before lunch`,
156
+ (a) => `latency is a choice, choose ${a('zero')}`,
157
+ (a) => `build ${a('better')}, ship ${a('faster')}`,
158
+ ];
159
+
160
+ /** A random brand tagline, accent words colored. */
161
+ export function tagline(): string {
162
+ return TAGLINES[Math.floor(Math.random() * TAGLINES.length)](brand);
163
+ }
164
+
165
+ /** Prints the brand banner: gradient logo + random tagline + version. */
106
166
  export function banner(): void {
107
167
  const lines = colorEnabled() ? ART.map(gradientLine) : ART.slice();
108
- const tagline = ` the most performant ${brand('react')} framework`;
109
168
  const ver = `${dim(' v')}${brand(version())}`;
110
- process.stdout.write('\n' + lines.join('\n') + '\n\n' + tagline + ' ' + ver + '\n\n');
169
+ process.stdout.write('\n' + lines.join('\n') + '\n\n ' + tagline() + ' ' + ver + '\n\n');
111
170
  }
Binary file
@@ -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
- sendWasmResponse(response, root, result);
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) {
@@ -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 from toiljs\n');
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).toBeGreaterThanOrEqual(200);
190
- expect(r.status).toBeLessThan(500);
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
+ });