toiljs 0.0.67 → 0.0.68
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 +5 -0
- package/README.md +63 -61
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +13 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +2 -0
- package/build/client/index.js +1 -0
- package/build/client/rpc.js +21 -1
- package/build/client/stream/client.d.ts +11 -0
- package/build/client/stream/client.js +59 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +9 -7
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +16 -2
- package/build/compiler/toil-docs.generated.js +2 -2
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/runtime.d.ts +13 -0
- package/build/devserver/daemon/runtime.js +29 -0
- package/build/devserver/db/database.d.ts +1 -0
- package/build/devserver/db/database.js +10 -0
- package/build/devserver/db/derives.d.ts +7 -0
- package/build/devserver/db/derives.js +94 -0
- package/build/devserver/db/index.d.ts +1 -0
- package/build/devserver/db/index.js +1 -0
- package/build/devserver/db/types.d.ts +1 -0
- package/build/devserver/db/types.js +1 -0
- package/build/devserver/http/proxy.d.ts +5 -1
- package/build/devserver/http/proxy.js +39 -36
- package/build/devserver/http/runtime.d.ts +62 -0
- package/build/devserver/http/runtime.js +194 -0
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +1 -0
- package/build/devserver/production-ipc.d.ts +50 -0
- package/build/devserver/production-ipc.js +21 -0
- package/build/devserver/production-worker.d.ts +1 -0
- package/build/devserver/production-worker.js +73 -0
- package/build/devserver/production.d.ts +35 -0
- package/build/devserver/production.js +502 -0
- package/build/devserver/runtime/module.d.ts +5 -0
- package/build/devserver/runtime/module.js +47 -1
- package/build/devserver/server.d.ts +1 -0
- package/build/devserver/server.js +32 -145
- package/build/devserver/ssr.d.ts +2 -0
- package/build/devserver/ssr.js +19 -2
- package/build/devserver/stream/catalog.d.ts +20 -0
- package/build/devserver/stream/catalog.js +54 -0
- package/build/devserver/stream/host.d.ts +9 -0
- package/build/devserver/stream/host.js +15 -0
- package/build/devserver/stream/index.d.ts +37 -0
- package/build/devserver/stream/index.js +220 -0
- package/build/devserver/stream/manager.d.ts +34 -0
- package/build/devserver/stream/manager.js +103 -0
- package/build/devserver/stream/router.d.ts +25 -0
- package/build/devserver/stream/router.js +64 -0
- package/build/devserver/stream/wire.d.ts +5 -0
- package/build/devserver/stream/wire.js +33 -0
- package/build/devserver/stream/ws.d.ts +18 -0
- package/build/devserver/stream/ws.js +46 -0
- package/docs/cli.md +3 -1
- package/docs/getting-started.md +7 -7
- package/examples/basic/server/routes/Guestbook.ts +38 -13
- package/package.json +2 -2
- package/src/cli/index.ts +14 -1
- package/src/client/index.ts +2 -0
- package/src/client/rpc.ts +25 -1
- package/src/client/stream/client.ts +107 -0
- package/src/compiler/config.ts +15 -7
- package/src/compiler/index.ts +24 -5
- package/src/compiler/toil-docs.generated.ts +2 -2
- package/src/devserver/daemon/runtime.ts +48 -0
- package/src/devserver/db/database.ts +14 -0
- package/src/devserver/db/derives.ts +121 -0
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +6 -0
- package/src/devserver/http/proxy.ts +53 -39
- package/src/devserver/http/runtime.ts +287 -0
- package/src/devserver/index.ts +2 -0
- package/src/devserver/production-ipc.ts +63 -0
- package/src/devserver/production-worker.ts +83 -0
- package/src/devserver/production.ts +706 -0
- package/src/devserver/runtime/module.ts +95 -1
- package/src/devserver/server.ts +52 -201
- package/src/devserver/ssr.ts +23 -3
- package/src/devserver/stream/catalog.ts +106 -0
- package/src/devserver/stream/host.ts +42 -0
- package/src/devserver/stream/index.ts +308 -0
- package/src/devserver/stream/manager.ts +163 -0
- package/src/devserver/stream/router.ts +101 -0
- package/src/devserver/stream/wire.ts +58 -0
- package/src/devserver/stream/ws.ts +76 -0
- package/test/built-ssr.test.ts +98 -0
- package/test/devserver.test.ts +20 -4
- package/test/example-guestbook.test.ts +8 -5
- package/test/fixtures/stream-echo.ts +26 -0
- package/test/fixtures/stream-gate.ts +24 -0
- package/test/fixtures/stream-trap.ts +18 -0
- package/test/stream-emulation.test.ts +394 -0
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.68",
|
|
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": {
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"nodemailer": "^9.0.1",
|
|
135
135
|
"picocolors": "^1.1.1",
|
|
136
136
|
"sharp": "^0.35.2",
|
|
137
|
-
"toilscript": "^0.1.
|
|
137
|
+
"toilscript": "^0.1.43",
|
|
138
138
|
"typescript-eslint": "^8.62.0",
|
|
139
139
|
"vite": "^8.1.0",
|
|
140
140
|
"vite-imagetools": "^10.0.1",
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface Flags {
|
|
|
19
19
|
root?: string;
|
|
20
20
|
port?: number;
|
|
21
21
|
host?: string;
|
|
22
|
+
threads?: number;
|
|
22
23
|
name?: string;
|
|
23
24
|
template?: Template;
|
|
24
25
|
preprocessor?: Preprocessor;
|
|
@@ -51,6 +52,12 @@ function parseArgs(argv: string[]): Flags {
|
|
|
51
52
|
case '--host':
|
|
52
53
|
flags.host = argv[++i];
|
|
53
54
|
break;
|
|
55
|
+
case '--threads':
|
|
56
|
+
case '--workers': {
|
|
57
|
+
const threads = Number(argv[++i]);
|
|
58
|
+
if (!Number.isNaN(threads)) flags.threads = threads;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
54
61
|
case '--template':
|
|
55
62
|
case '-t': {
|
|
56
63
|
const t = argv[++i];
|
|
@@ -138,6 +145,7 @@ function printHelp(): void {
|
|
|
138
145
|
bold('Options'),
|
|
139
146
|
cmd('--root <dir>', 'project root (default: current directory)'),
|
|
140
147
|
cmd('--port <n>', 'dev server port'),
|
|
148
|
+
cmd('--threads <n>', 'start: production HTTP worker count'),
|
|
141
149
|
cmd('-t, --template', 'create: app | minimal'),
|
|
142
150
|
cmd('--style <name>', 'create/configure: css | sass | less | stylus'),
|
|
143
151
|
cmd('--tailwind', 'create/configure: enable Tailwind (--no-tailwind to remove)'),
|
|
@@ -226,7 +234,12 @@ async function main(): Promise<void> {
|
|
|
226
234
|
case 'start': {
|
|
227
235
|
banner();
|
|
228
236
|
process.stdout.write(dim(' self-hosting the built app…') + '\n\n');
|
|
229
|
-
const server = await start({
|
|
237
|
+
const server = await start({
|
|
238
|
+
root: flags.root,
|
|
239
|
+
port: flags.port,
|
|
240
|
+
host: flags.host,
|
|
241
|
+
threads: flags.threads,
|
|
242
|
+
});
|
|
230
243
|
process.stdout.write(
|
|
231
244
|
accent(' ➜ ') +
|
|
232
245
|
bold(`http://localhost:${String(server.port)}`) +
|
package/src/client/index.ts
CHANGED
|
@@ -79,6 +79,8 @@ export { matchRoute } from './routing/match.js';
|
|
|
79
79
|
export type { RouteParams } from './routing/match.js';
|
|
80
80
|
export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel.js';
|
|
81
81
|
export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
|
|
82
|
+
export { makeStreamClient } from './stream/client.js';
|
|
83
|
+
export type { StreamChannel, StreamConnectable, StreamClient } from './stream/client.js';
|
|
82
84
|
export { useHead, useTitle, Head, mergeHead } from './head/head.js';
|
|
83
85
|
export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
|
|
84
86
|
export { resolveMetadata, useMetadata, Metadata } from './head/metadata.js';
|
package/src/client/rpc.ts
CHANGED
|
@@ -24,7 +24,26 @@ function restMissingStub(path: string): unknown {
|
|
|
24
24
|
return new Proxy(call, {
|
|
25
25
|
get(_target, prop) {
|
|
26
26
|
if (typeof prop === 'symbol' || prop === 'then') return undefined;
|
|
27
|
-
return restMissingStub(`${path}.${
|
|
27
|
+
return restMissingStub(`${path}.${prop}`);
|
|
28
|
+
},
|
|
29
|
+
apply() {
|
|
30
|
+
return call();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A recursive proxy that throws on call, used when the stream client hasn't loaded. */
|
|
36
|
+
function streamMissingStub(path: string): unknown {
|
|
37
|
+
const call = (): never => {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`toiljs Stream: ${path}() is unavailable. The generated stream client has not loaded - ` +
|
|
40
|
+
`import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
return new Proxy(call, {
|
|
44
|
+
get(_target, prop) {
|
|
45
|
+
if (typeof prop === 'symbol' || prop === 'then') return undefined;
|
|
46
|
+
return streamMissingStub(`${path}.${prop}`);
|
|
28
47
|
},
|
|
29
48
|
apply() {
|
|
30
49
|
return call();
|
|
@@ -49,6 +68,11 @@ function rpcStub(path: string): unknown {
|
|
|
49
68
|
const rest = (globalThis as { __toilRest?: unknown }).__toilRest;
|
|
50
69
|
return rest !== undefined ? rest : restMissingStub('Server.REST');
|
|
51
70
|
}
|
|
71
|
+
// `Server.Stream` surfaces the generated stream client attached by shared/server.ts.
|
|
72
|
+
if (path === 'Server' && prop === 'Stream') {
|
|
73
|
+
const stream = (globalThis as { __toilStream?: unknown }).__toilStream;
|
|
74
|
+
return stream !== undefined ? stream : streamMissingStub('Server.Stream');
|
|
75
|
+
}
|
|
52
76
|
return rpcStub(`${path}.${prop}`);
|
|
53
77
|
},
|
|
54
78
|
apply() {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client runtime for the typed `@stream` channel (doc 08 section 8.2). `Server.Stream.<Class>.connect(path?)`
|
|
3
|
+
* opens a browser `WebSocket` to the class's `route` (same origin) and returns a channel:
|
|
4
|
+
* `onMessage` / `send` / `onClose` / `close`. RAW byte mode (the default `@message` bridge); the typed
|
|
5
|
+
* `@data` codec (`messageMode = 'data'`) is a follow-up. The generated `shared/server.ts` (toilscript
|
|
6
|
+
* hot pass) attaches `makeStreamClient(routes)` to `globalThis.__toilStream`, and `Server.Stream`
|
|
7
|
+
* (`rpc.ts`) surfaces it - the same wiring the REST client uses via `globalThis.__toilRest`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A live `@stream` connection. `TSend` is the outbound message type: `Uint8Array` for a raw `@stream`,
|
|
11
|
+
* or the `@data` class for a typed `@stream({ message: T })` (the channel encodes it before sending).
|
|
12
|
+
* The inbound reply is ALWAYS raw bytes - the server's `StreamOutbound` is raw (doc 03 2.5). */
|
|
13
|
+
export interface StreamChannel<TSend = Uint8Array> {
|
|
14
|
+
/** Register the inbound-frame handler (one call per server reply frame). */
|
|
15
|
+
onMessage(cb: (data: Uint8Array) => void): void;
|
|
16
|
+
/** Send one outbound message (one `@message` on the server); a typed channel encodes it first. */
|
|
17
|
+
send(data: TSend): void;
|
|
18
|
+
/** Register the close handler (`code` is the `0x02xx` stream close code, or the WS code). */
|
|
19
|
+
onClose(cb: (code: number) => void): void;
|
|
20
|
+
/** Close the connection. */
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The connect factory for one `@stream` class. `path` is the `@connect` path (default `''`). */
|
|
25
|
+
export interface StreamConnectable<TSend = Uint8Array> {
|
|
26
|
+
connect(path?: string): Promise<StreamChannel<TSend>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `Server.Stream`: one `connect()` factory per `@stream` class name. */
|
|
30
|
+
export type StreamClient = Record<string, StreamConnectable>;
|
|
31
|
+
|
|
32
|
+
/** Open a WebSocket to `url` and resolve a channel once the upgrade completes; reject if the socket
|
|
33
|
+
* closes/errors BEFORE it opens (a 421 redirect / wrong-node / unreachable). A server close AFTER
|
|
34
|
+
* open (e.g. a `@connect` reject or a guest reject) surfaces through `onClose(code)`. */
|
|
35
|
+
function connectStream<TSend = Uint8Array>(
|
|
36
|
+
url: string,
|
|
37
|
+
encode?: (msg: never) => Uint8Array,
|
|
38
|
+
): Promise<StreamChannel<TSend>> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const ws = new WebSocket(url);
|
|
41
|
+
ws.binaryType = 'arraybuffer';
|
|
42
|
+
let opened = false;
|
|
43
|
+
let messageCb: ((data: Uint8Array) => void) | undefined;
|
|
44
|
+
let closeCb: ((code: number) => void) | undefined;
|
|
45
|
+
|
|
46
|
+
const channel: StreamChannel<TSend> = {
|
|
47
|
+
onMessage: (cb): void => {
|
|
48
|
+
messageCb = cb;
|
|
49
|
+
},
|
|
50
|
+
onClose: (cb): void => {
|
|
51
|
+
closeCb = cb;
|
|
52
|
+
},
|
|
53
|
+
send: (data): void => {
|
|
54
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
55
|
+
// A typed channel encodes the @data message; a raw channel sends the bytes as-is.
|
|
56
|
+
const bytes = encode ? encode(data as never) : (data as unknown as Uint8Array);
|
|
57
|
+
ws.send(bytes as BufferSource);
|
|
58
|
+
},
|
|
59
|
+
close: (): void => {
|
|
60
|
+
ws.close();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ws.addEventListener('open', () => {
|
|
65
|
+
opened = true;
|
|
66
|
+
resolve(channel);
|
|
67
|
+
});
|
|
68
|
+
ws.addEventListener('message', (event: MessageEvent) => {
|
|
69
|
+
if (event.data instanceof ArrayBuffer) messageCb?.(new Uint8Array(event.data));
|
|
70
|
+
});
|
|
71
|
+
ws.addEventListener('close', (event: CloseEvent) => {
|
|
72
|
+
if (!opened) reject(new Error(`stream connect failed (closed ${String(event.code)})`));
|
|
73
|
+
else closeCb?.(event.code);
|
|
74
|
+
});
|
|
75
|
+
ws.addEventListener('error', () => {
|
|
76
|
+
if (!opened) reject(new Error('stream connect error'));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The same-origin WebSocket base (`ws://` / `wss://` per the page protocol). */
|
|
82
|
+
function defaultOrigin(): string {
|
|
83
|
+
const loc = globalThis.location;
|
|
84
|
+
return `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the `Server.Stream` client from the generated route map (`{ ClassName: route }`). `origin`
|
|
89
|
+
* defaults to the page origin. `encoders` carries one `@data` encoder per typed `@stream({ message: T })`
|
|
90
|
+
* class (the generated `(m) => m.encode()`); a class with no entry is a raw byte channel. The generated
|
|
91
|
+
* `shared/server.ts` calls this and assigns the result to `globalThis.__toilStream`.
|
|
92
|
+
*/
|
|
93
|
+
export function makeStreamClient(
|
|
94
|
+
routes: Record<string, string>,
|
|
95
|
+
origin?: string,
|
|
96
|
+
encoders?: Record<string, (msg: never) => Uint8Array>,
|
|
97
|
+
): StreamClient {
|
|
98
|
+
const base = origin ?? defaultOrigin();
|
|
99
|
+
const client: StreamClient = {};
|
|
100
|
+
for (const [name, route] of Object.entries(routes)) {
|
|
101
|
+
const encode = encoders?.[name];
|
|
102
|
+
client[name] = {
|
|
103
|
+
connect: (path = ''): Promise<StreamChannel> => connectStream(`${base}${route}${path}`, encode),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return client;
|
|
107
|
+
}
|
package/src/compiler/config.ts
CHANGED
|
@@ -142,6 +142,11 @@ export interface ServerConfig {
|
|
|
142
142
|
readonly nodeMode?: DevNodeMode;
|
|
143
143
|
/** Daemon (L4) config mirror (dev / self-host). */
|
|
144
144
|
readonly daemon?: DaemonConfig;
|
|
145
|
+
/**
|
|
146
|
+
* Self-host HTTP worker count for `toiljs start`. Default `auto`
|
|
147
|
+
* (`os.availableParallelism()`). Set `1` to disable the worker pool.
|
|
148
|
+
*/
|
|
149
|
+
readonly threads?: number | 'auto';
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
/** Fully-resolved {@link DaemonConfig}; every field non-optional, defaults applied. */
|
|
@@ -199,6 +204,8 @@ export interface ResolvedToilConfig {
|
|
|
199
204
|
readonly nodeMode: DevNodeMode;
|
|
200
205
|
/** Daemon (L4) config mirror (dev / self-host), every field resolved. */
|
|
201
206
|
readonly daemon: ResolvedDaemonConfig;
|
|
207
|
+
/** Self-host HTTP worker count for `toiljs start`. */
|
|
208
|
+
readonly threads: number | 'auto';
|
|
202
209
|
/** Absolute path to the framework client runtime (`toiljs/client`). */
|
|
203
210
|
readonly runtimePath: string;
|
|
204
211
|
readonly vite: InlineConfig;
|
|
@@ -273,18 +280,13 @@ export async function loadConfig(
|
|
|
273
280
|
email: user.server?.email ?? null,
|
|
274
281
|
nodeMode: resolveNodeMode(user.server?.nodeMode),
|
|
275
282
|
daemon: resolveDaemonConfig(user.server?.daemon),
|
|
283
|
+
threads: resolveThreads(user.server?.threads),
|
|
276
284
|
runtimePath: resolveRuntimePath(),
|
|
277
285
|
vite: client.vite ?? {},
|
|
278
286
|
};
|
|
279
287
|
}
|
|
280
288
|
|
|
281
|
-
const DEV_NODE_MODES: readonly DevNodeMode[] = [
|
|
282
|
-
'hot',
|
|
283
|
-
'regional',
|
|
284
|
-
'continental',
|
|
285
|
-
'daemon',
|
|
286
|
-
'all',
|
|
287
|
-
];
|
|
289
|
+
const DEV_NODE_MODES: readonly DevNodeMode[] = ['hot', 'regional', 'continental', 'daemon', 'all'];
|
|
288
290
|
|
|
289
291
|
/** A `nodeMode` outside the enum falls back to `all` with a warning (fail-soft:
|
|
290
292
|
* the authoritative role is the edge's TCF + plan, so dev never bricks on it). */
|
|
@@ -319,3 +321,9 @@ function resolveDaemonConfig(d: DaemonConfig | undefined): ResolvedDaemonConfig
|
|
|
319
321
|
maxTasks,
|
|
320
322
|
};
|
|
321
323
|
}
|
|
324
|
+
|
|
325
|
+
function resolveThreads(threads: number | 'auto' | undefined): number | 'auto' {
|
|
326
|
+
if (threads === undefined || threads === 'auto') return 'auto';
|
|
327
|
+
if (!Number.isFinite(threads)) return 'auto';
|
|
328
|
+
return Math.max(1, Math.min(128, Math.floor(threads)));
|
|
329
|
+
}
|
package/src/compiler/index.ts
CHANGED
|
@@ -524,6 +524,8 @@ export interface ToilCommandOptions {
|
|
|
524
524
|
readonly port?: number;
|
|
525
525
|
/** Bind host for `start`. Defaults to loopback (`127.0.0.1`); pass `0.0.0.0` to expose. */
|
|
526
526
|
readonly host?: string;
|
|
527
|
+
/** `start` only: production HTTP worker count. Defaults to `server.threads` / auto. */
|
|
528
|
+
readonly threads?: number;
|
|
527
529
|
/** `build` only: build the server (regenerate `shared/server.ts` + the wasm) and skip the client. */
|
|
528
530
|
readonly serverOnly?: boolean;
|
|
529
531
|
}
|
|
@@ -699,6 +701,10 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
699
701
|
// The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
|
|
700
702
|
// project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
|
|
701
703
|
coldWasmFile: serverArtifacts(cfg.root).cold,
|
|
704
|
+
// The stream router serves `@stream`-route WebSocket upgrades from `release-stream.wasm` per
|
|
705
|
+
// `nodeMode`; the path points at the (maybe-not-yet-built) stream artifact, mtime-reloaded so
|
|
706
|
+
// a `@stream` build activates it, and harmless for a project with no `@stream` (no routes).
|
|
707
|
+
streamWasmFile: serverArtifacts(cfg.root).stream,
|
|
702
708
|
nodeMode: cfg.nodeMode,
|
|
703
709
|
daemon: cfg.daemon,
|
|
704
710
|
vite: { host: '127.0.0.1', port: vitePort },
|
|
@@ -779,9 +785,9 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
779
785
|
}
|
|
780
786
|
|
|
781
787
|
/**
|
|
782
|
-
* Self-hosts the built
|
|
783
|
-
*
|
|
784
|
-
* `build`. Returns the running backend.
|
|
788
|
+
* Self-hosts the built app over the high-performance hyper-express backend (uWebSockets.js).
|
|
789
|
+
* Server projects use the built wasm + SSR templates before the SPA fallback; client-only projects
|
|
790
|
+
* use the static backend. Requires a prior `build`. Returns the running backend.
|
|
785
791
|
*/
|
|
786
792
|
export async function start(opts: ToilCommandOptions = {}): Promise<RunningBackend> {
|
|
787
793
|
const cfg = await loadConfig(opts);
|
|
@@ -789,8 +795,21 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
|
|
|
789
795
|
if (!fs.existsSync(path.join(outDir, 'index.html'))) {
|
|
790
796
|
throw new Error(`No build found in ${outDir}. Run \`toiljs build\` first.`);
|
|
791
797
|
}
|
|
792
|
-
const
|
|
793
|
-
|
|
798
|
+
const wasmFile = serverWasmFile(cfg.root);
|
|
799
|
+
const { startBuiltServer } = await import('toiljs/devserver');
|
|
800
|
+
const artifacts = serverArtifacts(cfg.root);
|
|
801
|
+
return startBuiltServer({
|
|
802
|
+
root: cfg.root,
|
|
803
|
+
staticRoot: outDir,
|
|
804
|
+
wasmFile: fs.existsSync(wasmFile) ? wasmFile : undefined,
|
|
805
|
+
coldWasmFile: artifacts.cold,
|
|
806
|
+
nodeMode: cfg.nodeMode,
|
|
807
|
+
daemon: cfg.daemon,
|
|
808
|
+
threads: opts.threads ?? cfg.threads,
|
|
809
|
+
port: cfg.port,
|
|
810
|
+
host: opts.host,
|
|
811
|
+
email: cfg.email ?? undefined,
|
|
812
|
+
});
|
|
794
813
|
}
|
|
795
814
|
|
|
796
815
|
export { defineConfig, loadConfig, AiProvider } from './config.js';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
/** The framework guides written into `.toil/docs/`, keyed by filename, generated from `docs/`. */
|
|
7
7
|
export const TOIL_DOCS: Record<string, string> = {
|
|
8
8
|
"index.md": "# toiljs\n\nA full-stack React framework: a Vite-bundled client SPA with file-based routing, plus a\ntoilscript-to-WebAssembly server target.\n\n## Project layout\n\n- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,\n `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).\n- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.\n `@data`/`@remote`/`@service` here generate the typed client `Server` API (see [server.md](./server.md)).\n- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).\n- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient\n globals), `toil-routes.d.ts` (typed routes), `shared/server.ts` (the typed RPC module,\n emitted by the server build; import `@data` classes from `shared/server`).\n\n## Key ideas\n\n- `Toil` is a native global (no import): `Toil.Link`, `Toil.useRouter`, `Toil.useLoaderData`,\n etc. The IO classes (`FastMap`, `FastSet`, `DataWriter`, `DataReader`), `parseError`, and the\n generated `Server` RPC surface are globals too.\n- Scripts: `npm run dev` (HMR), `npm run build` (→ `build/client` + `build/server`),\n `npm start` (self-host the build).\n- Compute tiers: the server can span L1 request (`server/main.ts`, `@rest`/`@service`/`@remote`),\n L2/L3 stream (`server/main.stream.ts`, `@stream`), and L4 daemon (`server/main.daemon.ts`,\n `@daemon`/`@scheduled`); each tier compiles into its own artifact. See [tiers.md](./tiers.md).\n\nSee [routing.md](./routing.md), [client.md](./client.md), [styling.md](./styling.md),\n[server.md](./server.md), [ssr.md](./ssr.md), [rpc.md](./rpc.md), [tiers.md](./tiers.md),\n[streams.md](./streams.md), [daemon.md](./daemon.md), [cli.md](./cli.md).\n",
|
|
9
|
-
"getting-started.md": "# Getting started\n\nA toiljs app has two halves that build and ship together:\n\n- a **server** written in ToilScript, compiled to a single WebAssembly module\n (`build/server/release.wasm`), and\n- a **client** (Vite + React) that talks to the server through a generated,\n fully typed `Server` proxy.\n\nThe server runs one fresh wasm instance per request, identically on the dev\nserver and on the edge. There is no Node.js in the request path: your handler is\nwasm.\n\n## Project layout\n\n```\nproject/\n toilconfig.json server (wasm) build config: entries, target, AS options\n toil.config.ts client config (defineConfig: dev/build/SEO options)\n\n server/\n main.ts wires Server.handler, re-exports the wasm exports + abort\n routes/*.ts @rest controllers (auto-discovered)\n services/*.ts @service / @remote (auto-discovered)\n core/AppHandler.ts your top-level ToilHandler\n models/*.ts @data / @user classes\n\n shared/\n server.ts GENERATED by the server build (--rpcModule): the typed\n client surface (Server proxy, @data codecs, getUser)\n\n client/\n routes/*.tsx file-based pages\n layout.tsx, 404.tsx root layout / not-found\n styles/*.css\n\n build/\n server/release.wasm compiled server (+ release.wat text form)\n client/ Vite output\n```\n\nThe compiler discovers every `.ts` under `server/` that declares a decorated\nsurface (`@rest`, `@service`, `@remote`, `@data`, `@user`) on its own. Importing\nthose modules from `main.ts` is still good practice: it keeps a direct\n`toilscript` run (which only sees the `toilconfig.json` entries) building the\nexact same server.\n\n## `main.ts`\n\nThree things are required, and the comments in the scaffold say so:\n\n```ts\nimport { Server } from 'toiljs/server/runtime';\nimport { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\nimport { AppHandler } from './core/AppHandler';\n\n// Pull every decorated surface into a direct `toilscript` build.\nimport './routes/Players';\nimport './services/Stats';\n\n// 1. The handler factory: one fresh handler instance per request.\nServer.handler = () => new AppHandler();\n\n// 2. Re-export the wasm entrypoints (`handle`, `render`).\nexport * from 'toiljs/server/runtime/exports';\n\n// 3. The AssemblyScript trap hook.\nexport function abort(message: string, fileName: string, line: u32, column: u32): void {\n revertOnError(message, fileName, line, column);\n}\n```\n\nIf all you need is `@rest` routing, your handler can be `RestHandler` (see\n[Routing](./routing.md)) and you do not have to write an `AppHandler` at all.\n\n## The request lifecycle\n\nFor each request the runtime (`server/runtime/exports`):\n\n1. decodes the request envelope into a [`Request`](./routing.md#request),\n2. publishes it ambiently as `Server.currentRequest` (so `AuthService.getUser()`\n and friends can read its cookies with no argument),\n3. builds the handler via `Server.handler()` and calls\n `onRequestStarted` → `handle(req)` → `onRequestCompleted`,\n4. encodes the returned [`Response`](./routing.md#response) and clears the\n ambient request.\n\nBecause the instance is fresh and memory is wiped between requests, **nothing in\nmodule globals survives across requests.** Anything that must persist (accounts,\nsessions you do not put in a cookie, rate-limit counters) belongs in an external\nstore reached through a host binding.\n\n## CLI\n\nThe `toiljs` CLI drives both halves:\n\n| Command
|
|
9
|
+
"getting-started.md": "# Getting started\n\nA toiljs app has two halves that build and ship together:\n\n- a **server** written in ToilScript, compiled to a single WebAssembly module\n (`build/server/release.wasm`), and\n- a **client** (Vite + React) that talks to the server through a generated,\n fully typed `Server` proxy.\n\nThe server runs one fresh wasm instance per request, identically on the dev\nserver and on the edge. There is no Node.js in the request path: your handler is\nwasm.\n\n## Project layout\n\n```\nproject/\n toilconfig.json server (wasm) build config: entries, target, AS options\n toil.config.ts client config (defineConfig: dev/build/SEO options)\n\n server/\n main.ts wires Server.handler, re-exports the wasm exports + abort\n routes/*.ts @rest controllers (auto-discovered)\n services/*.ts @service / @remote (auto-discovered)\n core/AppHandler.ts your top-level ToilHandler\n models/*.ts @data / @user classes\n\n shared/\n server.ts GENERATED by the server build (--rpcModule): the typed\n client surface (Server proxy, @data codecs, getUser)\n\n client/\n routes/*.tsx file-based pages\n layout.tsx, 404.tsx root layout / not-found\n styles/*.css\n\n build/\n server/release.wasm compiled server (+ release.wat text form)\n client/ Vite output\n```\n\nThe compiler discovers every `.ts` under `server/` that declares a decorated\nsurface (`@rest`, `@service`, `@remote`, `@data`, `@user`) on its own. Importing\nthose modules from `main.ts` is still good practice: it keeps a direct\n`toilscript` run (which only sees the `toilconfig.json` entries) building the\nexact same server.\n\n## `main.ts`\n\nThree things are required, and the comments in the scaffold say so:\n\n```ts\nimport { Server } from 'toiljs/server/runtime';\nimport { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\nimport { AppHandler } from './core/AppHandler';\n\n// Pull every decorated surface into a direct `toilscript` build.\nimport './routes/Players';\nimport './services/Stats';\n\n// 1. The handler factory: one fresh handler instance per request.\nServer.handler = () => new AppHandler();\n\n// 2. Re-export the wasm entrypoints (`handle`, `render`).\nexport * from 'toiljs/server/runtime/exports';\n\n// 3. The AssemblyScript trap hook.\nexport function abort(message: string, fileName: string, line: u32, column: u32): void {\n revertOnError(message, fileName, line, column);\n}\n```\n\nIf all you need is `@rest` routing, your handler can be `RestHandler` (see\n[Routing](./routing.md)) and you do not have to write an `AppHandler` at all.\n\n## The request lifecycle\n\nFor each request the runtime (`server/runtime/exports`):\n\n1. decodes the request envelope into a [`Request`](./routing.md#request),\n2. publishes it ambiently as `Server.currentRequest` (so `AuthService.getUser()`\n and friends can read its cookies with no argument),\n3. builds the handler via `Server.handler()` and calls\n `onRequestStarted` → `handle(req)` → `onRequestCompleted`,\n4. encodes the returned [`Response`](./routing.md#response) and clears the\n ambient request.\n\nBecause the instance is fresh and memory is wiped between requests, **nothing in\nmodule globals survives across requests.** Anything that must persist (accounts,\nsessions you do not put in a cookie, rate-limit counters) belongs in an external\nstore reached through a host binding.\n\n## CLI\n\nThe `toiljs` CLI drives both halves:\n\n| Command | What it does |\n| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `toiljs create [name]` | Scaffold a new app (templates, styling, options). |\n| `toiljs dev` | Dev server with hot reload: watches `server/`, rebuilds the wasm via toilscript, regenerates `shared/server.ts`, and runs Vite for the client. Flags: `--root <dir>`, `--port <n>`, `--host`. |\n| `toiljs build` | Production build: server wasm first (so `shared/server.ts` is fresh), then the Vite client + static prerender. Flags: `--root <dir>`, `--server` (server only). |\n| `toiljs start` | Self-host a built app with production uWS/static workers, no Vite. Flags: `--root`, `--port`, `--host`, `--threads`. |\n| `toiljs doctor` | Diagnose setup/deps (`--json`, `--fix`). |\n\nIn dev, requests whose method matches a dispatchable verb go into the wasm\nfirst; if the guest reports \"no route matched\" (the `x-toil-unhandled` marker)\nthe request falls through to Vite, so client routes and assets just work\nalongside your API.\n\n## Building the server by hand\n\n`toiljs build` runs toilscript for you, but you can invoke it directly (this is\nwhat the examples do):\n\n```sh\ntoilscript --target release --rpcModule shared/server.ts\n```\n\n`--target release` reads `toilconfig.json` and emits the wasm at\n`targets.release.outFile`; `--rpcModule shared/server.ts` writes the generated\ntyped client (see [RPC](./rpc.md)).\n\n## Next\n\n- [Routing](./routing.md) to expose HTTP endpoints.\n- [Data codec](./data.md) for request/response bodies.\n- [Auth](./auth.md) for login and sessions.\n",
|
|
10
10
|
"routing.md": "# Routing\n\ntoiljs routing is decorator-driven. You write a controller class, annotate it\nwith `@rest` and its methods with verb decorators, and the ToilScript compiler\ngenerates the dispatcher. Routes can take a typed body, read path params and the\nraw request through a `RouteContext`, and return either a `Response` or a typed\nvalue that is auto-encoded.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('players')\nclass Players {\n @get('/:id')\n public get(ctx: RouteContext): Response {\n const id = ctx.param('id');\n return Response.json(`{\"id\":\"${id}\"}`);\n }\n\n @post('/')\n public create(input: NewPlayer): Player {\n // `input` is the decoded request body; returning a @data value JSON-encodes it\n return Player.from(input);\n }\n}\n```\n\n## `@rest` controllers\n\n`@rest` marks a class as a route controller and mounts it at a prefix.\n\n```ts\n@rest('api') // mounted at /api\n@rest('/') // or @rest('') // mounted at the root\n@rest({ stream: DataStream.Binary }) // root mount, binary codec by default\n```\n\n- The string argument is the mount prefix. `\"api\"`, `\"/api\"`, and `\"api/\"` all\n normalize to `/api`; `\"\"` and `\"/\"` mean the root.\n- The object form sets class-wide defaults. `stream: DataStream.Binary` makes\n every route in the class use the binary `@data` codec; the default is\n `DataStream.JSON`. Individual routes override this with `@route`.\n\nThe compiler injects, at module init, a registration that adds the controller to\nthe global `Rest` registry. Controllers dispatch in the order their modules are\nloaded; routes within a controller try in declaration order, first match wins.\n\n## Verb decorators\n\nEach HTTP method has a decorator taking a single path string:\n\n```ts\n@get('/path') @post('/path') @put('/path') @delete('/path')\n@patch('/path') @head('/path') @options('/path')\n```\n\nThe full path is the controller prefix joined with the route path\n(`prefix=\"/api\"`, `@get(\"/todos/:id\")` → `/api/todos/:id`).\n\n### `@route` (explicit form)\n\n`@route` is the general form; use it when you need to set the stream mode per\nroute or prefer an object:\n\n```ts\n@route({ method: Methods.POST, path: '/upload', stream: DataStream.Binary })\npublic upload(body: FileData): FileResult { /* ... */ }\n```\n\n`method` (from the `Methods` enum) and `path` are required; `stream` is\noptional and overrides the controller default.\n\n## Path parameters\n\nA `:name` segment captures that URL segment. Read it with `ctx.param(\"name\")`:\n\n```ts\n@get('/todos/:id/items/:itemId')\npublic getItem(ctx: RouteContext): Response {\n const id = ctx.param('id');\n const itemId = ctx.param('itemId');\n return Response.json(`{\"todo\":\"${id}\",\"item\":\"${itemId}\"}`);\n}\n```\n\nMatching is segment-exact: the request path must have the same number of\nsegments, static segments must match literally, and `:param` segments capture\nthe value. The query string is stripped before matching.\n\n## Method parameters\n\nA route method takes zero, one, or two parameters, classified by type:\n\n- a `RouteContext` parameter receives the match context (path params, query,\n headers, raw body);\n- any other type is treated as the **request body**, decoded as a `@data` value.\n\n```ts\n@get('/status')\npublic status(): StatusResponse { /* no body, no context */ }\n\n@get('/user/:id')\npublic getUser(ctx: RouteContext): User { /* context only */ }\n\n@post('/create')\npublic create(input: NewTodo): Todo { /* body only */ }\n\n@post('/user/:id/score')\npublic addScore(input: ScoreDelta, ctx: RouteContext): Player {\n const id = ctx.param('id'); /* body AND context */\n}\n```\n\nThe body is decoded per the route's stream mode: in JSON mode from\n`JSON.parse(ctx.text())`, in Binary mode from `Body.decode(req.body)`. See\n[Data codec](./data.md).\n\n## Return types\n\nThe compiler encodes the return value by its type:\n\n| Return type | Result |\n| --- | --- |\n| `Response` | Returned as-is. Full control over status, headers, body. |\n| `void` | `204 No Content`. |\n| a `@data` type, JSON stream | `Response.json(value.toJSON().toString())`. |\n| a `@data` type, Binary stream | `Response.bytes(value.encode())`. |\n\nReturning a `Response` lets you set status, headers, cookies, and caching\ndirectly; returning a typed value is the terse path when you just want the data\nserialized.\n\n## Data streams\n\nEach route is either **JSON** (default) or **Binary**:\n\n- **JSON**, the body is `JSON.parse`d and revived via the `@data` type's\n `fromJSON`; the response is the type's `toJSON()`. 64-bit-and-larger integers\n cross the wire as decimal strings (exact at any size). Best for endpoints a\n browser or third party calls directly.\n- **Binary**, the body is `Body.decode(bytes)` and the response is\n `value.encode()`, using the deterministic `DataWriter`/`DataReader` codec. No\n precision loss, smaller, faster. Best for app-to-app and anything\n security-sensitive.\n\nSet the mode on the controller (`@rest({ stream: DataStream.Binary })`) or per\nroute (`@route({ ..., stream: DataStream.Binary })`).\n\n## Dispatch and the 404 fallback\n\nAt runtime the global `Rest` registry tries each controller in order:\n\n```ts\nconst hit = Rest.dispatch(req); // Response | null\nif (hit != null) return hit; // first matching route's Response\nreturn Response.unhandled(); // no route matched\n```\n\n`RestHandler` is a ready-made handler that does exactly this, so a REST-only app\nneeds no custom handler:\n\n```ts\nimport { RestHandler } from 'toiljs/server/runtime';\nServer.handler = () => new RestHandler();\n```\n\n`Response.unhandled()` is a `404` carrying the `x-toil-unhandled` marker header.\nOn the dev server and edge that marker means \"no route matched here\" and lets\nthe request fall through to the next layer (Vite in dev, static/SSR on the\nedge). A deliberate `Response.notFound()` does **not** carry the marker and is\nsent to the client verbatim.\n\n---\n\n## `Request`\n\nThe decoded incoming request (`server/runtime/request.ts`).\n\n### Fields\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `method` | `Method` | `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `UNKNOWN`. |\n| `path` | `string` | Path including the query string. |\n| `headers` | `Array<Header>` | Ordered; a `Header` is `{ name, value }`. |\n| `body` | `Uint8Array` | Raw request body bytes. |\n\n### Methods\n\n| Method | Signature | Notes |\n| --- | --- | --- |\n| `header` | `header(name: string): string \\| null` | Case-insensitive lookup, `null` if absent. |\n| `cookies` | `cookies(): CookieMap` | Parses the `Cookie` header (percent-decoded values); cached for the request. |\n| `cookie` | `cookie(name: string): string \\| null` | A single cookie value, or `null`. |\n\nThe `Method` enum and `Header` class are exported from\n`toiljs/server/runtime`.\n\n## `RouteContext`\n\nPassed to any route method that declares a `RouteContext` parameter\n(`server/runtime/rest/RouteContext.ts`).\n\n| Member | Signature | Notes |\n| --- | --- | --- |\n| `request` | `Request` | The raw incoming request. |\n| `param` | `param(name: string): string` | Captured path param; `\"\"` if absent. |\n| `query` | `query(name: string): string` | Query-string value; `\"\"` if absent. Not URL-decoded in v1. |\n| `header` | `header(name: string): string \\| null` | Case-insensitive request header. |\n| `text` | `text(): string` | The request body decoded as UTF-8. |\n\n## `Response`\n\nThe outgoing response builder (`server/runtime/response.ts`). Construct one with\na static factory, then chain instance methods (each returns the same `Response`).\n\n### Constructor\n\n```ts\nnew Response(status: u16, body: Uint8Array, headers: Array<Header> | null = null)\n```\n\n### Static factories\n\n| Factory | Signature | Status | Content-Type |\n| --- | --- | --- | --- |\n| `Response.text` | `text(body: string, status: u16 = 200)` | 200 | `text/plain; charset=utf-8` |\n| `Response.html` | `html(body: string, status: u16 = 200)` | 200 | `text/html; charset=utf-8` |\n| `Response.json` | `json(body: string, status: u16 = 200)` | 200 | `application/json; charset=utf-8` |\n| `Response.bytes` | `bytes(body: Uint8Array, status: u16 = 200)` | 200 | `application/octet-stream` |\n| `Response.empty` | `empty(status: u16)` | custom | (none) |\n| `Response.notFound` | `notFound()` | 404 | text |\n| `Response.badRequest` | `badRequest(msg = 'bad request')` | 400 | text |\n| `Response.internalError` | `internalError(msg = 'internal error')` | 500 | text |\n| `Response.unhandled` | `unhandled()` | 404 | text + `x-toil-unhandled` marker |\n\n`json` takes an already-serialized string; build it with `DataWriter`-free JSON\nor a `@data` type's `toJSON().toString()`. For binary, prefer `bytes`.\n\n### Instance methods\n\n| Method | Signature | Notes |\n| --- | --- | --- |\n| `setHeader` | `setHeader(name: string, value: string): Response` | Appends a header (repeatable). |\n| `setCookie` | `setCookie(cookie: Cookie): Response` | Appends a `Set-Cookie`. Call again for more. |\n| `setCookieKV` | `setCookieKV(name: string, value: string): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |\n| `clearCookie` | `clearCookie(name: string, path = '/', domain = ''): Response` | Emits a deletion `Set-Cookie` (empty value, `Max-Age=0`). |\n| `cache` | `cache(edgeTtlMinutes: u16, browserTtlSeconds: u32 = 0, privateScope: bool = false, allowAuth: bool = false): Response` | Marks the response cacheable. See [Caching](./caching.md). |\n| `cacheFor` | `cacheFor(minutes: u16): Response` | Shorthand for `cache(minutes)` (edge only). |\n\n```ts\nreturn Response.json('{\"id\":42}')\n .setHeader('x-trace', traceId)\n .setCookie(Cookie.create('sid', token).httpOnly().secure())\n .cacheFor(5);\n```\n\nSee [Cookies](./cookies.md) for the cookie builder, and [Caching](./caching.md)\nfor the cache directives.\n",
|
|
11
11
|
"client.md": "# Client runtime\n\nEverything is on the `Toil` global, no imports needed in route files.\n\n## Entry\n\n`client/toil.tsx` imports the route table + global styles and mounts the app:\n\n```tsx\nimport { routes, layout, notFound } from \"toiljs/routes\";\nimport \"./styles/main.css\";\nToil.mount(routes, layout, notFound);\n```\n\n## API (on `Toil`)\n\n- Components: `Link`, `NavLink`, `Head`\n- Navigation: `navigate`, `useRouter`, `useNavigate`\n- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`\n- Data: `useLoaderData` (see [routing.md](./routing.md))\n- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route\n- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)\n- IO globals (no `Toil.` prefix): `FastMap`, `FastSet`, `DataWriter`, `DataReader`\n- `parseError(err)` global: message from an unknown caught value (handy in `catch`)\n- `Server` global: the typed RPC surface generated from the server (see [server.md](./server.md))\n- `Server.REST.<controller>.<route>(args)`: a working, typed `fetch` client for your\n `@rest` controllers, e.g. `await Server.REST.todos.getTodo({ params: { id } })` or\n `await Server.REST.todos.add({ body: new AddTodo(\"milk\") })`. `args` is\n `{ params?, body?, query?, headers? }`; returns are typed (`@data` classes are parsed for\n you). The REST client attaches when you import from `shared/server`.\n\n## Head example\n\n```tsx\nToil.useHead({\n title: \"Blog\",\n titleTemplate: \"%s, MyApp\",\n meta: [{ name: \"description\", content: \"...\" }],\n});\n```\n",
|
|
12
12
|
"styling.md": "# Styling\n\nThe app imports one stylesheet from `client/toil.tsx` (e.g. `./styles/main.css`).\n\n## Preprocessors & Tailwind\n\nPick a CSS preprocessor (none / Sass / Less / Stylus) and optionally Tailwind at\n`toiljs create`, or change it later on an existing project:\n\n```sh\ntoiljs configure # interactive\ntoiljs configure --tailwind # add Tailwind\ntoiljs configure --style sass # switch preprocessor\n```\n\n`configure` installs/removes the right packages and rewrites the imports. Tailwind lives\nin its own `styles/tailwind.css` (`@import \"tailwindcss\";`).\n\n## Imports\n\n`.css` / `.scss` / `.sass` / `.less` / `.styl` and image imports (`.svg`, `.png`, …) are\ntyped via `toil-env.d.ts`.\n",
|
|
@@ -25,5 +25,5 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
25
25
|
"cookies.md": "# Cookies\n\nA complete HTTP cookie layer for the toiljs server runtime, covering the full\nRFC 6265bis surface (including `SameSite`, the `Partitioned`/CHIPS attribute, and\nthe `__Host-` / `__Secure-` prefixes) plus cryptographic signing and encryption.\n\n`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` /\n`CookieEncoding` / `CookiePrefix` enums are **ambient globals**: a handler uses\nthem with **no import**, exactly like `crypto`. They are also exported from\n`toiljs/server/runtime` for anyone who prefers an explicit import.\n\n- [How \"global, no import\" works](#how-global-no-import-works)\n- [Quick start](#quick-start)\n- [The `Cookie` builder](#the-cookie-builder)\n- [The `Cookies` parser and codec](#the-cookies-parser-and-codec)\n- [`CookieMap`](#cookiemap)\n- [`SecureCookies` signing and encryption](#securecookies-signing-and-encryption)\n- [`Request` and `Response` integration](#request-and-response-integration)\n- [`base64url` helpers](#base64url-helpers)\n- [Encoding vs encryption](#encoding-vs-encryption)\n- [Security notes](#security-notes)\n- [Spec compliance](#spec-compliance)\n- [Testing](#testing)\n- [API reference](#api-reference)\n\n---\n\n## How \"global, no import\" works\n\nThe cookie types are declared with ToilScript's `@global` decorator and pulled\ninto every server build (re-exported from `toiljs/server/runtime` and\nside-effect-imported by `toiljs/server/runtime/exports`, which every `main.ts`\nre-exports). At compile time the symbols register in the global scope, so a\nhandler can write `Cookie.create(...)` or `req.cookie(...)` without importing\nanything.\n\nFor the editor, `toiljs create` scaffolds `server/toil-server-env.d.ts` with\nambient `declare`s for these globals (the toilscript compiler ignores `.d.ts`;\nit only feeds the language service). If you would rather import them:\n\n```ts\nimport { Cookie, Cookies, SecureCookies, SameSite } from 'toiljs/server/runtime';\n```\n\n---\n\n## Quick start\n\n```ts\nimport { ToilHandler, Request, Response } from 'toiljs/server/runtime';\n\nexport class AppHandler extends ToilHandler {\n public handle(req: Request): Response {\n // Read (no import needed for Cookie / Cookies / SameSite, they are global).\n const sid = req.cookie('sid'); // string | null\n\n // Write a hardened session cookie.\n return Response.json('{\"ok\":true}').setCookie(\n Cookie.create('sid', 'abc123')\n .httpOnly()\n .secure()\n .sameSite(SameSite.Lax)\n .maxAge(3600)\n .asHostPrefixed(), // forces Secure + Path=/ + no Domain\n );\n }\n}\n```\n\n---\n\n## The `Cookie` builder\n\nA fluent builder that serializes to one `Set-Cookie` field value. Every setter\nreturns the cookie, so calls chain.\n\n```ts\nconst c = Cookie.create('id', 'abc123')\n .domain('example.com')\n .path('/app')\n .maxAge(3600)\n .secure()\n .httpOnly()\n .sameSite(SameSite.Lax);\n\nc.serialize();\n// \"id=abc123; Domain=example.com; Path=/app; Max-Age=3600; SameSite=Lax; Secure; HttpOnly\"\n```\n\n### Fields\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `name` | `string` | The cookie name (a token; never encoded). |\n| `value` | `string` | The logical value (encoded per `encoding` on serialize). |\n| `encoding` | `CookieEncoding` | Wire encoding for the value. Default `Percent`. |\n\n### Construction\n\n- `new Cookie(name, value)`\n- `Cookie.create(name, value): Cookie`, a builder-style alias.\n\n### Attribute setters\n\n| Method | Attribute |\n| --- | --- |\n| `domain(v: string)` | `Domain` |\n| `path(v: string)` | `Path` (must begin with `/`) |\n| `maxAge(seconds: i64)` | `Max-Age` (`0` / negative expire immediately) |\n| `expires(epochSeconds: i64)` | `Expires`, formatted as an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) |\n| `expiresRaw(date: string)` | `Expires` verbatim (escape hatch) |\n| `secure(on: bool = true)` | `Secure` |\n| `httpOnly(on: bool = true)` | `HttpOnly` |\n| `sameSite(s: SameSite)` | `SameSite` |\n| `partitioned(on: bool = true)` | `Partitioned` (CHIPS) |\n| `priority(p: string)` | `Priority` (`Low` / `Medium` / `High`) |\n| `extension(av: string)` | An arbitrary extension attribute, appended verbatim |\n| `withEncoding(e: CookieEncoding)` | Choose the value wire encoding |\n\n### Prefixes\n\n- `asSecurePrefixed(): Cookie`, prepends `__Secure-` and forces `Secure`.\n- `asHostPrefixed(): Cookie`, prepends `__Host-` and forces `Secure`, `Path=/`, and no `Domain`.\n- `detectedPrefix(): CookiePrefix`, the prefix detected on the current name (case-insensitive).\n\n### Output\n\n- `serialize(strict: bool = false): string`, returns the `Set-Cookie` value. Lenient by\n default (always returns a best-effort cookie); pass `strict = true` to throw on\n a hard validation failure. `Secure` is added automatically when `SameSite=None`\n or `Partitioned` is set; `Max-Age` is clamped to the 400-day cap; control\n characters are stripped from the name, value, and attributes.\n- `toString(): string`, alias for `serialize()`.\n- `encodedValue(): string`, the value transformed per `encoding`.\n\n### Validation\n\n`validate(): CookieValidation` checks the cookie against RFC 6265bis and returns\na structured result:\n\n```ts\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n```\n\nIt flags: a non-token name, name+value over 4096 bytes, a `Domain`/`Path` over\n1024 bytes, a `Path` not starting with `/`, a `Raw` value outside `cookie-octet`,\nthe `__Host-` / `__Secure-` prefix requirements, `SameSite=None` or `Partitioned`\nwithout `Secure`, and a `Max-Age` beyond the 400-day cap.\n\n### Attribute serialization order\n\n`name=value` then, when set: `Domain`, `Path`, `Expires`, `Max-Age`, `SameSite`,\n`Secure`, `HttpOnly`, `Partitioned`, `Priority`, then any `extension(...)` values.\n(Attribute order is not significant to user agents; the order is stable so output\nis predictable.)\n\n### Enums\n\n```ts\nenum SameSite { Default, None, Lax, Strict } // Default omits the attribute\nenum CookieEncoding { Percent, Raw, Base64Url } // value wire encoding\nenum CookiePrefix { None, Secure, Host }\n```\n\n---\n\n## The `Cookies` parser and codec\n\nStatic helpers for the read side and a one-shot serializer.\n\n| Method | Description |\n| --- | --- |\n| `Cookies.parse(cookieHeader: string): CookieMap` | Parse a request `Cookie` header (`a=1; b=2`). Values are percent-decoded; one layer of surrounding quotes is stripped; malformed pairs and empty names are skipped. On a duplicate name the first wins. |\n| `Cookies.get(cookieHeader: string, name: string): string \\| null` | Parse and return one value. |\n| `Cookies.serialize(name: string, value: string): string` | One-shot `name=value` with no attributes (percent-encoded). For attributes, build a `Cookie`. |\n| `Cookies.parseSetCookie(setCookie: string): Cookie` | Parse a `Set-Cookie` line back into a `Cookie` (for clients, tests, proxies). Kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the wire form. |\n| `Cookies.encodeValue(raw: string): string` | Percent-encode a value (the default `Cookie` encoding). |\n| `Cookies.decodeValue(enc: string): string` | Percent-decode a value (the inverse). |\n\n```ts\nconst jar = Cookies.parse('sid=abc123; theme=dark');\njar.get('sid'); // \"abc123\"\n\nCookies.serialize('sid', 'a b'); // \"sid=a%20b\"\n```\n\n---\n\n## `CookieMap`\n\nThe ordered name to value view returned by `Cookies.parse` and `Request.cookies()`.\nBacked by parallel arrays (a request carries a handful of cookies, so a linear\nscan beats hashing and keeps the runtime small).\n\n| Member | Description |\n| --- | --- |\n| `get(name: string): string \\| null` | The value, or `null`. |\n| `has(name: string): bool` | Whether the cookie is present. |\n| `names(): Array<string>` | A copy of the names, in encounter order. |\n| `size: i32` | The number of cookies. |\n| `set(name: string, value: string): void` | Insert unless present (keep-first). Used by `parse`; rarely called directly. |\n\n---\n\n## `SecureCookies` signing and encryption\n\nTamper-proof and confidential cookie values, built on the `crypto` global (no new\nhost functions).\n\n- **`SecureCookies.signed(key)`**: HMAC-SHA256. The value stays readable but is\n bound to the cookie name, so it cannot be tampered with or moved to another\n cookie. Sealed form: `base64url(value) \".\" base64url(mac)`.\n- **`SecureCookies.encrypted(key)`**: AES-256-GCM (or AES-128-GCM) with a random\n 96-bit IV and the cookie name as additional authenticated data. The value is\n confidential and authenticated. Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.\n\nKeys are caller-supplied raw bytes:\n\n- HMAC: any length (32+ bytes recommended).\n- AES: exactly 16 or 32 bytes (enforced up front; a wrong length is rejected by\n the factory).\n\n```ts\n// A real app loads a long random secret from config; never hard-code one.\nconst key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));\n\n// Signed (readable, tamper-proof)\nconst signer = SecureCookies.signed(key);\nconst sealed = signer.sign('session', 'user-42');\nconst user = signer.unsign('session', sealed); // \"user-42\", or null if tampered\n\n// Encrypted (confidential + authenticated)\nconst box = SecureCookies.encrypted(key);\nresp.setCookie(box.seal(Cookie.create('secret', 'top-secret').httpOnly()));\nconst secret = box.open(req.cookies(), 'secret'); // \"top-secret\", or null\n```\n\n| Method | Description |\n| --- | --- |\n| `SecureCookies.signed(key: Uint8Array)` | HMAC-SHA256 signer/verifier. |\n| `SecureCookies.encrypted(key: Uint8Array)` | AES-GCM (16- or 32-byte key). |\n| `addKey(key: Uint8Array): SecureCookies` | Add a fallback key for rotation: seal with the first, open with any. |\n| `sign(name, value): string` | Sealed signed value. |\n| `unsign(name, sealed): string \\| null` | Verify and recover, or `null`. |\n| `encrypt(name, value): string` | Sealed encrypted value. |\n| `decrypt(name, sealed): string \\| null` | Decrypt, or `null`. |\n| `seal(cookie: Cookie): Cookie` | Seal a cookie's value in place (sign or encrypt per the instance mode) and mark it `Raw`. Returns the same cookie. |\n| `open(jar: CookieMap, name): string \\| null` | Read and open cookie `name` from a parsed jar. |\n\n**Key rotation:** seal with `keys[0]`; `unsign` / `decrypt` try every key in turn,\nso you can add a new key as the first and keep an old one as a fallback while\nexisting cookies age out.\n\n```ts\nconst signer = SecureCookies.signed(newKey).addKey(oldKey);\n```\n\n---\n\n## `Request` and `Response` integration\n\nBecause every handler already has a `Request` and returns a `Response`, the most\ncommon operations live there directly.\n\n**Read (`Request`):**\n\n| Method | Description |\n| --- | --- |\n| `req.cookies(): CookieMap` | All cookies, parsed from the `Cookie` header (cached for the request). |\n| `req.cookie(name: string): string \\| null` | One cookie value. |\n\n**Write (`Response`, builder-style):**\n\n| Method | Description |\n| --- | --- |\n| `resp.setCookie(cookie: Cookie): Response` | Append a `Set-Cookie`. Each call adds its own header (cookies are never folded). |\n| `resp.setCookieKV(name, value): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |\n| `resp.clearCookie(name, path = '/', domain = ''): Response` | Append a deletion cookie (empty value, `Max-Age=0`, epoch `Expires`). `path` / `domain` must match the original. |\n\n---\n\n## `base64url` helpers\n\nUnpadded base64url (RFC 4648 §5), used internally by `SecureCookies` and exported\nfor convenience. Its alphabet (`A-Z a-z 0-9 - _`) is within the `cookie-octet`\ngrammar and invariant under percent-encoding, so encoded values round-trip\ncleanly through the default cookie codec.\n\n| Function | Description |\n| --- | --- |\n| `base64UrlEncode(data: Uint8Array): string` | Encode bytes as unpadded base64url. |\n| `base64UrlDecode(s: string): Uint8Array \\| null` | Decode base64url/base64 (padding and whitespace tolerated); `null` on an invalid character or length. |\n\n---\n\n## Encoding vs encryption\n\nTwo independent layers, easy to mix up:\n\n- **Encoding** (`CookieEncoding`) is transport-only and reversible by anyone. It\n keeps an arbitrary value inside the `cookie-octet` grammar.\n - `Percent` (default): `encodeURIComponent`-style; arbitrary UTF-8 is safe.\n - `Base64Url`: UTF-8 then base64url.\n - `Raw`: no transformation (the value must already be valid `cookie-octet`).\n- **Signing / encryption** (`SecureCookies`) is cryptographic. Signing keeps the\n value readable but tamper-proof; encryption makes it unreadable and\n authenticated. Both require a secret key.\n\n`SecureCookies.seal` sets the value to its sealed (base64url) form and marks the\ncookie `Raw`, so it passes through the default parse path untouched.\n\n---\n\n## Security notes\n\n- **Panic-free verification.** `unsign` and `decrypt` return `null` on a tampered,\n truncated, or wrong-key value, never a trap. (`decrypt` reads the host return\n code directly instead of letting the underlying crypto throw, because the\n server runs with exceptions disabled.) This makes them safe to call on\n attacker-controlled input.\n- **Name-binding.** Signing MACs `name + \"=\" + value`; encryption uses the name as\n AAD. A sealed value made for one cookie name will not verify or decrypt under\n another.\n- **Control characters are stripped** from the name, value, and attribute values\n on serialize, as a defense-in-depth guard against header injection (CR/LF).\n Control characters are invalid in all of these per the grammar, so nothing\n legitimate is lost. The default value encoding already neutralizes CR/LF.\n- **Prefixes.** `asHostPrefixed()` / `asSecurePrefixed()` apply and enforce the\n browser-recognized guarantees; `validate()` reports a name that carries a prefix\n without satisfying its requirements.\n- **`SameSite=None` and `Partitioned` imply `Secure`** and are emitted with it\n automatically.\n- **Lifetime is clamped** to the RFC 400-day cap on serialize; sizes are checked by\n `validate()`.\n- **Local development.** Browsers treat `http://localhost` as a secure context, so\n `Secure` and `__Host-` cookies work under `toiljs dev` over plain HTTP.\n\nWhen putting untrusted input into a cookie **name** or **attribute** (rather than\nthe value, which is encoded by default), check `validate()` or use\n`serialize(true)`.\n\n---\n\n## Spec compliance\n\nImplements RFC 6265bis (HTTP State Management) and the `Partitioned` (CHIPS)\ncompanion: the `cookie-name` token and `cookie-value` `cookie-octet` grammars,\nthe `Expires` / `Max-Age` / `Domain` / `Path` / `Secure` / `HttpOnly` /\n`SameSite` / `Partitioned` attributes plus `Priority` and arbitrary extensions,\nthe `__Host-` / `__Secure-` prefixes (matched case-insensitively), the 4096-byte\nname+value and 1024-byte attribute limits, the 400-day lifetime cap, the\n`SameSite=None` ⇒ `Secure` rule, and the requirement that each cookie occupy its\nown `Set-Cookie` header (never folded).\n\n---\n\n## Testing\n\n- Pure cookie logic (builder, parser, codec, validation, `Request` / `Response`\n integration) is unit-tested with as-pect in `test/assembly/cookie.spec.ts`\n (`npm run test:server`).\n- `SecureCookies` is exercised end-to-end against the real toilscript-compiled\n wasm with the Node-backed crypto host in `test/devserver.test.ts`\n (`npm test`). It is tested there rather than under as-pect because the as-pect\n compiler does not ship the toilscript crypto standard library.\n\nA live demo (every attribute's serialized output, set/inspect/clear, and an\ninteractive sign/encrypt) is in the example app: run `toiljs dev` in\n`examples/basic` and open `/cookies`. The backend lives in\n`examples/basic/server/core/AppHandler.ts`.\n\n---\n\n## API reference\n\n```ts\n// Globals (also exported from 'toiljs/server/runtime')\n\nenum SameSite { Default, None, Lax, Strict }\nenum CookieEncoding { Percent, Raw, Base64Url }\nenum CookiePrefix { None, Secure, Host }\n\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n\nclass Cookie {\n name: string;\n value: string;\n encoding: CookieEncoding;\n static create(name: string, value: string): Cookie;\n domain(v: string): Cookie;\n path(v: string): Cookie;\n maxAge(seconds: i64): Cookie;\n expires(epochSeconds: i64): Cookie;\n expiresRaw(date: string): Cookie;\n secure(on?: bool): Cookie;\n httpOnly(on?: bool): Cookie;\n sameSite(s: SameSite): Cookie;\n partitioned(on?: bool): Cookie;\n priority(p: string): Cookie;\n extension(av: string): Cookie;\n withEncoding(e: CookieEncoding): Cookie;\n asSecurePrefixed(): Cookie;\n asHostPrefixed(): Cookie;\n detectedPrefix(): CookiePrefix;\n encodedValue(): string;\n validate(): CookieValidation;\n serialize(strict?: bool): string;\n toString(): string;\n}\n\nclass CookieMap {\n get(name: string): string | null;\n has(name: string): bool;\n names(): Array<string>;\n size: i32;\n set(name: string, value: string): void;\n}\n\nclass Cookies {\n static parse(cookieHeader: string): CookieMap;\n static get(cookieHeader: string, name: string): string | null;\n static serialize(name: string, value: string): string;\n static parseSetCookie(setCookie: string): Cookie;\n static encodeValue(raw: string): string;\n static decodeValue(enc: string): string;\n}\n\nclass SecureCookies {\n static signed(key: Uint8Array): SecureCookies;\n static encrypted(key: Uint8Array): SecureCookies;\n addKey(key: Uint8Array): SecureCookies;\n sign(name: string, value: string): string;\n unsign(name: string, sealed: string): string | null;\n encrypt(name: string, value: string): string;\n decrypt(name: string, sealed: string): string | null;\n seal(cookie: Cookie): Cookie;\n open(jar: CookieMap, name: string): string | null;\n}\n\nfunction base64UrlEncode(data: Uint8Array): string;\nfunction base64UrlDecode(s: string): Uint8Array | null;\n\n// On Request\nreq.cookies(): CookieMap;\nreq.cookie(name: string): string | null;\n\n// On Response (builder-style)\nresp.setCookie(cookie: Cookie): Response;\nresp.setCookieKV(name: string, value: string): Response;\nresp.clearCookie(name: string, path?: string, domain?: string): Response;\n```\n",
|
|
26
26
|
"crypto.md": "# Web Crypto\n\nThe guest gets a synchronous Web Crypto surface through the ambient `crypto`\nglobal, backed by host functions. It mirrors the browser `crypto` /\n`crypto.subtle` API but **without Promises**, ToilScript has no `async`, so\nevery call returns its result directly. Keys are opaque per-request handles in a\nhost keystore; a `CryptoKey` is valid only for the request that created it.\n\n```ts\nconst mac = crypto.hmacSha256(key, message); // Uint8Array\nconst id = crypto.randomUUID(); // string\n```\n\nThis is also what [`SecureCookies`](./cookies.md) and\n[`AuthService`](./auth.md) are built on, so most apps use crypto indirectly.\n\n## `crypto` namespace\n\nConvenience helpers (all synchronous):\n\n| Function | Signature | Notes |\n| --- | --- | --- |\n| `getRandomValues` | `(array: Uint8Array): void` | Fill with CSPRNG bytes. |\n| `randomUUID` | `(): string` | RFC 4122 v4 UUID. |\n| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | One-shot digests. |\n| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode then digest. |\n| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | One-shot HMAC-SHA256. |\n| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA256 over a UTF-8 string. |\n| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex. |\n| `subtle` | `SubtleCrypto` | The full primitive surface (below). |\n\n## `crypto.subtle`\n\n| Method | Signature |\n| --- | --- |\n| `digest` | `digest(algorithm: string, data: Uint8Array): Uint8Array` |\n| `importKey` | `importKey(format: string, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |\n| `exportKey` | `exportKey(format: string, key: CryptoKey): Uint8Array` |\n| `encrypt` | `encrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `decrypt` | `decrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `sign` | `sign(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `verify` | `verify(algorithm: AlgorithmParams, key: CryptoKey, signature: Uint8Array, data: Uint8Array): bool` |\n| `deriveBits` | `deriveBits(algorithm: AlgorithmParams, baseKey: CryptoKey, length: i32): Uint8Array` |\n| `deriveKey` | `deriveKey(algorithm, baseKey, lengthBits, derivedKeyAlgorithm, extractable, usages): CryptoKey` |\n\n`digest` takes a named algorithm string (`\"SHA-1\"`, `\"SHA-256\"`, `\"SHA-384\"`,\n`\"SHA-512\"`, `\"SHA3-256\"`, `\"SHA3-384\"`, `\"SHA3-512\"`). `verify` returns a bool\n(it does not throw on a mismatch). Formats are `raw`, `pkcs8`, `spki`; **`jwk`\nis not supported**.\n\n### Algorithm parameter classes\n\n`crypto` and `crypto.subtle` are ambient globals (no import). The params classes\nand the `ALG_*` / `USAGE_*` / `FMT_*` / `CURVE_*` constants and the `CryptoKey`\ntype are imported from the `'crypto'` module:\n\n```ts\nimport { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_SIGN } from 'crypto';\n```\n\nEach algorithm has a small params class you pass to `importKey`/`sign`/etc.:\n\n| Class | Constructor |\n| --- | --- |\n| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` |\n| `AesCbcParams` | `(iv)` |\n| `AesCtrParams` | `(counter, length = 128)` |\n| `HmacImportParams` | `(hash)` |\n| `HmacParams` | `()` |\n| `Pbkdf2Params` | `(hash, salt, iterations)` |\n| `HkdfParams` | `(hash, salt, info?)` |\n| `EcdsaParams` | `(hash)` |\n| `EcKeyImportParams` | `(alg, namedCurve)` |\n| `Ed25519Params` | `()` |\n| `X25519ImportParams` | `()` |\n| `EcdhParams` | `(alg, publicKeyHandle)` |\n\n### Constants\n\n- **Hashes / algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`,\n `ALG_SHA_512`, `ALG_SHA3_256/384/512`, `ALG_AES_GCM`, `ALG_AES_CBC`,\n `ALG_AES_CTR`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_HKDF`,\n `ALG_PBKDF2`.\n- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI` (`FMT_JWK` is rejected).\n- **Usages (bitmask):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`,\n `USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`,\n `USAGE_UNWRAP_KEY`, OR them together.\n- **Named curves:** `CURVE_P256`, `CURVE_P384` (`CURVE_P521` is not supported).\n\n### `CryptoKey`\n\nAn opaque handle plus metadata: `handle: i32`, `type: string`\n(`secret`/`public`/`private`), `extractable: bool`, `algorithm: i32`,\n`usages: i32`, with `algorithmName()` and `hasUsage(u)`. A key is valid only for\nthe request that imported it.\n\n## Examples\n\nHMAC-SHA256 (one-shot):\n\n```ts\nconst mac = crypto.hmacSha256(key, message);\nconst hex = crypto.toHex(mac);\n```\n\nAES-256-GCM via `subtle`:\n\n```ts\nconst key = new Uint8Array(32); crypto.getRandomValues(key);\nconst iv = new Uint8Array(12); crypto.getRandomValues(iv);\n\nconst k = crypto.subtle.importKey('raw', key, new AesGcmParams(iv, aad, 128), false, USAGE_ENCRYPT);\nconst ct = crypto.subtle.encrypt(new AesGcmParams(iv, aad, 128), k, plaintext);\n```\n\n## Post-quantum verify\n\nThe host also exposes ML-DSA-44 (FIPS 204) signature verification as\n`crypto.mldsa_verify`. It is verify-only, the host never holds a secret key, and\nunderpins the [auth primitive](./auth.md). Most code reaches it through\n`AuthService.verifyLogin(publicKey, message, signature)` rather than calling the\nimport directly. Public key is 1312 bytes, signature 2420 bytes, with a FIPS 204\ndomain-separation context.\n\n## Limitations\n\n- **No Promises**, every call is synchronous.\n- **No RSA** and **no JWK** key format.\n- **P-521** is not supported (P-256 and P-384 are).\n- Signature *generation* for ML-DSA is client-side only; the server verifies.\n",
|
|
27
27
|
"time.md": "# Time\n\n`Time` is the guest's wall-clock. It is the toiljs-blessed way to read the\ncurrent time, backed by the host's `Date.now()` binding (`env.Date.now`). Both\nthe edge and the dev server provide that binding, so time behaves identically in\n`toiljs dev` and in production.\n\nIt is available as an ambient global (`@global`, no import) and is also exported\nfrom `toiljs/server/runtime`.\n\n```ts\nimport { Time } from 'toiljs/server/runtime'; // optional; Time is also a global\n\nconst ms = Time.nowMillis(); // u64 milliseconds since the Unix epoch\nconst s = Time.nowSeconds(); // u64 whole seconds since the Unix epoch\n```\n\n## API\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `Time.nowMillis()` | `static nowMillis(): u64` | Milliseconds since the Unix epoch (the raw host `Date.now()` value). |\n| `Time.nowSeconds()` | `static nowSeconds(): u64` | Whole seconds since the epoch (`nowMillis() / 1000`). The unit used by sessions and login challenges. |\n\n## Semantics\n\n`Time` is **wall-clock, not monotonic**, exactly like browser `Date.now()`. It\ntracks the system clock and can step backward across an NTP correction.\n\n- Use it to stamp and compare absolute instants: session `iat`/`exp`, login\n challenge expiry, cache ages.\n- Do **not** use it to measure elapsed time or as a high-resolution timer; a\n backward step would produce a negative or zero interval.\n\n## Relationship to `Date.now()`\n\nToilScript's `Date.now()` lowers to the same `env.Date.now` host import, so you\n*can* call it directly. Prefer `Time`: it makes the host boundary (and the\nsingle millisecond unit) explicit and easy to find, and it gives you\n`nowSeconds()` without an open-coded `/ 1000` cast at every call site.\n\n`AuthService` uses `Time.nowSeconds()` internally for session `iat`/`exp`, so\nsession timing and any timing you do in a handler share one clock.\n",
|
|
28
|
-
"cli.md": "# CLI\n\n- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,\n `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.\n- `toiljs dev`, dev server with HMR (`--port`, `--root`). With a `toilconfig.json` it builds\n the server first, then rebuilds it whenever a `server/` file changes (regenerating\n `shared/server.ts`, which Vite HMRs into the client); client-only edits just HMR the client.\n- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,\n regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds\n only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.\n- `toiljs start`, self-host the built app
|
|
28
|
+
"cli.md": "# CLI\n\n- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,\n `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.\n- `toiljs dev`, dev server with HMR (`--port`, `--root`). With a `toilconfig.json` it builds\n the server first, then rebuilds it whenever a `server/` file changes (regenerating\n `shared/server.ts`, which Vite HMRs into the client); client-only edits just HMR the client.\n- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,\n regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds\n only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.\n- `toiljs start`, self-host the built app with production hyper-express/uWS static workers,\n SSR/wasm dispatch, daemon support, and a `/_toil` WebSocket channel. Use `--threads <n>`\n (or `server.threads`) to set the worker count; `1` disables the pool.\n- `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).\n- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC\n setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the\n toilscript prettier plugin) so an existing project upgrades in one command.\n",
|
|
29
29
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
import { DaemonHost, daemonEmulationEnabled } from './index.js';
|
|
4
|
+
import type { ResolvedDaemonConfig } from './host.js';
|
|
5
|
+
|
|
6
|
+
export interface DaemonRuntimeOptions {
|
|
7
|
+
readonly coldWasmFile?: string;
|
|
8
|
+
readonly nodeMode?: string;
|
|
9
|
+
readonly daemon?: ResolvedDaemonConfig;
|
|
10
|
+
readonly pollMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunningDaemonRuntime {
|
|
14
|
+
readonly host: DaemonHost;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Starts the shared cold-artifact daemon runtime used by both `dev` and `start`. */
|
|
19
|
+
export function startDaemonRuntime(options: DaemonRuntimeOptions): RunningDaemonRuntime | null {
|
|
20
|
+
const nodeMode = options.nodeMode ?? 'all';
|
|
21
|
+
if (
|
|
22
|
+
options.coldWasmFile === undefined ||
|
|
23
|
+
!daemonEmulationEnabled(nodeMode) ||
|
|
24
|
+
options.daemon === undefined
|
|
25
|
+
) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const host = new DaemonHost(options.coldWasmFile, options.daemon, nodeMode);
|
|
30
|
+
const pollDaemon = (): void => {
|
|
31
|
+
try {
|
|
32
|
+
host.refresh();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
process.stdout.write(pc.red(` x daemon reload failed: ${String(e)}`) + '\n');
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
pollDaemon();
|
|
38
|
+
const timer = setInterval(pollDaemon, options.pollMs ?? 500);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
host,
|
|
43
|
+
close: (): void => {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
host.close();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -700,6 +700,7 @@ export class DevDatabase {
|
|
|
700
700
|
if (outcome.kind === 'unit') {
|
|
701
701
|
this.store.set(sk, value);
|
|
702
702
|
this.stampVersion(coll, sk); // stamp the value type's current schema version
|
|
703
|
+
this.recordWrite(db, coll);
|
|
703
704
|
}
|
|
704
705
|
this.recordIdemFinish(coll, key, 'C', idem, requestHash, outcome);
|
|
705
706
|
return this.replayRecordOutcome(db, outcome);
|
|
@@ -732,6 +733,7 @@ export class DevDatabase {
|
|
|
732
733
|
if (outcome.kind === 'value') {
|
|
733
734
|
this.store.set(sk, v);
|
|
734
735
|
this.stampVersion(coll, sk); // a patch rewrites the row at the current version
|
|
736
|
+
this.recordWrite(db, coll);
|
|
735
737
|
}
|
|
736
738
|
this.recordIdemFinish(coll, key, 'P', idem, requestHash, outcome);
|
|
737
739
|
return this.replayRecordOutcome(db, outcome);
|
|
@@ -1023,6 +1025,15 @@ export class DevDatabase {
|
|
|
1023
1025
|
|
|
1024
1026
|
// `delta` is the wasm i64 (a BigInt across the boundary); `BigInt()`
|
|
1025
1027
|
// normalizes the test's plain-number form too. Saturates like the edge.
|
|
1028
|
+
/** Note a successful write to a SOURCE collection, so the runtime can re-run
|
|
1029
|
+
* the `@derive` materializers that depend on it after this dispatch. A
|
|
1030
|
+
* derive's OWN writes run under FunctionKind=Derive and must never
|
|
1031
|
+
* re-trigger a derive (which would loop), so they are never recorded. */
|
|
1032
|
+
private recordWrite(db: DbDevState, coll: DevCollectionHandle): void {
|
|
1033
|
+
if (db.functionKind === DbFunctionKind.Derive) return;
|
|
1034
|
+
db.writtenCollections.add(coll.name);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1026
1037
|
counterAdd(
|
|
1027
1038
|
ref: MemoryRef,
|
|
1028
1039
|
db: DbDevState,
|
|
@@ -1045,6 +1056,7 @@ export class DevDatabase {
|
|
|
1045
1056
|
}
|
|
1046
1057
|
const sk = storeKey(coll.name, key);
|
|
1047
1058
|
this.counters.set(sk, satI64((this.counters.get(sk) ?? 0n) + d));
|
|
1059
|
+
this.recordWrite(db, coll);
|
|
1048
1060
|
return 0;
|
|
1049
1061
|
}
|
|
1050
1062
|
|
|
@@ -1086,6 +1098,7 @@ export class DevDatabase {
|
|
|
1086
1098
|
log.push(ev);
|
|
1087
1099
|
(this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
|
|
1088
1100
|
}
|
|
1101
|
+
this.recordWrite(db, coll);
|
|
1089
1102
|
return 0;
|
|
1090
1103
|
}
|
|
1091
1104
|
|
|
@@ -1124,6 +1137,7 @@ export class DevDatabase {
|
|
|
1124
1137
|
(this.eventVersions.get(sk) ?? this.eventVersions.set(sk, []).get(sk)!).push(sv);
|
|
1125
1138
|
}
|
|
1126
1139
|
seen.add(evid);
|
|
1140
|
+
this.recordWrite(db, coll);
|
|
1127
1141
|
return 1;
|
|
1128
1142
|
}
|
|
1129
1143
|
|