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.
- package/CHANGELOG.md +9 -0
- package/README.md +68 -34
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +73 -12
- 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/cli/notify.ts +4 -4
- package/src/cli/ui.ts +62 -3
- 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/test/ui.test.ts +40 -0
- package/examples/basic/server/api.ts +0 -137
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
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.
|
|
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
|
|
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
|
}
|
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('
|
|
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
|
|
94
|
+
return `${where} ${row.installed}${dim(', update with')} ${accent(row.command)}`;
|
|
95
95
|
});
|
|
96
|
-
return '\n' + [header, ...body]
|
|
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
|
-
|
|
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
|
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
|
+
});
|