toiljs 0.0.66 → 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 +10 -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 +22 -6
- package/build/compiler/toil-docs.generated.js +3 -3
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/index.js +4 -3
- package/build/devserver/daemon/runtime.d.ts +13 -0
- package/build/devserver/daemon/runtime.js +29 -0
- package/build/devserver/db/catalog.js +8 -12
- 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/build/devserver/wasm/surface.d.ts +1 -1
- package/build/devserver/wasm/surface.js +1 -1
- package/docs/cli.md +3 -1
- package/docs/getting-started.md +7 -7
- package/docs/tiers.md +15 -9
- 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 +43 -18
- package/src/compiler/toil-docs.generated.ts +3 -3
- package/src/devserver/daemon/index.ts +7 -7
- package/src/devserver/daemon/runtime.ts +48 -0
- package/src/devserver/db/catalog.ts +9 -13
- 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/src/devserver/wasm/surface.ts +5 -7
- package/test/built-ssr.test.ts +98 -0
- package/test/daemon-build.test.ts +15 -7
- package/test/daemon-catalog.test.ts +17 -8
- package/test/devserver-database.test.ts +8 -8
- 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/rpc-bignum-wire.test.ts +8 -8
- package/test/stream-emulation.test.ts +394 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { DevStreamBox } from './index.js';
|
|
3
|
+
export const STREAM_REJECTED = 0x0208;
|
|
4
|
+
export const STREAM_HOOK_TRAPPED = 0x0200;
|
|
5
|
+
export class StreamDevHost {
|
|
6
|
+
streamWasmPath;
|
|
7
|
+
bytes = null;
|
|
8
|
+
loadedMtimeMs = -1;
|
|
9
|
+
conns = new Map();
|
|
10
|
+
nextStreamId = 1n;
|
|
11
|
+
constructor(streamWasmPath) {
|
|
12
|
+
this.streamWasmPath = streamWasmPath;
|
|
13
|
+
}
|
|
14
|
+
get activeConnections() {
|
|
15
|
+
return this.conns.size;
|
|
16
|
+
}
|
|
17
|
+
has(connId) {
|
|
18
|
+
return this.conns.has(connId);
|
|
19
|
+
}
|
|
20
|
+
acceptUpgrade(connId, authority, path) {
|
|
21
|
+
if (this.conns.has(connId))
|
|
22
|
+
throw new Error(`stream connection '${connId}' is already open`);
|
|
23
|
+
this.refresh();
|
|
24
|
+
if (!this.bytes)
|
|
25
|
+
return { kind: 'rejected', code: STREAM_REJECTED };
|
|
26
|
+
let box;
|
|
27
|
+
try {
|
|
28
|
+
box = DevStreamBox.load(this.bytes);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { kind: 'rejected', code: STREAM_REJECTED };
|
|
32
|
+
}
|
|
33
|
+
const streamId = this.allocStreamId();
|
|
34
|
+
let outcome;
|
|
35
|
+
try {
|
|
36
|
+
outcome = box.onConnect(streamId, authority, path);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { kind: 'rejected', code: STREAM_HOOK_TRAPPED };
|
|
40
|
+
}
|
|
41
|
+
if (outcome.kind === 'reject')
|
|
42
|
+
return { kind: 'rejected', code: outcome.code };
|
|
43
|
+
this.conns.set(connId, { box, streamId });
|
|
44
|
+
return { kind: 'accepted', streamId };
|
|
45
|
+
}
|
|
46
|
+
dispatch(connId, inbound) {
|
|
47
|
+
const conn = this.conns.get(connId);
|
|
48
|
+
if (!conn)
|
|
49
|
+
return { kind: 'noConnection' };
|
|
50
|
+
try {
|
|
51
|
+
const out = conn.box.onMessage(conn.streamId, inbound);
|
|
52
|
+
if (out.kind === 'reply')
|
|
53
|
+
return { kind: 'reply', frames: out.frames };
|
|
54
|
+
return { kind: 'close', code: out.code };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
this.conns.delete(connId);
|
|
58
|
+
return { kind: 'close', code: STREAM_HOOK_TRAPPED };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
close(connId) {
|
|
62
|
+
const conn = this.conns.get(connId);
|
|
63
|
+
if (!conn)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
conn.box.onClose(conn.streamId);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
}
|
|
70
|
+
this.conns.delete(connId);
|
|
71
|
+
}
|
|
72
|
+
disconnect(connId) {
|
|
73
|
+
const conn = this.conns.get(connId);
|
|
74
|
+
if (!conn)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
conn.box.onDisconnect(conn.streamId);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
}
|
|
81
|
+
this.conns.delete(connId);
|
|
82
|
+
}
|
|
83
|
+
refresh() {
|
|
84
|
+
let mtimeMs;
|
|
85
|
+
try {
|
|
86
|
+
mtimeMs = fs.statSync(this.streamWasmPath).mtimeMs;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
this.bytes = null;
|
|
90
|
+
this.loadedMtimeMs = -1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (mtimeMs === this.loadedMtimeMs && this.bytes)
|
|
94
|
+
return;
|
|
95
|
+
this.bytes = fs.readFileSync(this.streamWasmPath);
|
|
96
|
+
this.loadedMtimeMs = mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
allocStreamId() {
|
|
99
|
+
const id = this.nextStreamId;
|
|
100
|
+
this.nextStreamId = id + 1n;
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type StreamDef } from './catalog.js';
|
|
2
|
+
export interface StreamWs {
|
|
3
|
+
send(data: Buffer, isBinary: boolean): void;
|
|
4
|
+
close(code: number): void;
|
|
5
|
+
on(event: 'message', cb: (message: Buffer, isBinary: boolean) => void): void;
|
|
6
|
+
on(event: 'close', cb: () => void): void;
|
|
7
|
+
}
|
|
8
|
+
export interface StreamUpgradeContext {
|
|
9
|
+
readonly kind: 'stream';
|
|
10
|
+
readonly route: string;
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly authority: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class StreamRouter {
|
|
15
|
+
private readonly streamWasmPath;
|
|
16
|
+
private catalog;
|
|
17
|
+
private catalogMtimeMs;
|
|
18
|
+
private readonly host;
|
|
19
|
+
private connSeq;
|
|
20
|
+
constructor(streamWasmPath: string);
|
|
21
|
+
get activeConnections(): number;
|
|
22
|
+
matchRoute(path: string): StreamDef | null;
|
|
23
|
+
onUpgrade(ws: StreamWs, ctx: StreamUpgradeContext): void;
|
|
24
|
+
private refreshCatalog;
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { matchStreamRoute, parseStreamCatalog, } from './catalog.js';
|
|
3
|
+
import { StreamDevHost } from './manager.js';
|
|
4
|
+
import { StreamWsSession } from './ws.js';
|
|
5
|
+
export class StreamRouter {
|
|
6
|
+
streamWasmPath;
|
|
7
|
+
catalog = new Map();
|
|
8
|
+
catalogMtimeMs = -1;
|
|
9
|
+
host;
|
|
10
|
+
connSeq = 0;
|
|
11
|
+
constructor(streamWasmPath) {
|
|
12
|
+
this.streamWasmPath = streamWasmPath;
|
|
13
|
+
this.host = new StreamDevHost(streamWasmPath);
|
|
14
|
+
this.refreshCatalog();
|
|
15
|
+
}
|
|
16
|
+
get activeConnections() {
|
|
17
|
+
return this.host.activeConnections;
|
|
18
|
+
}
|
|
19
|
+
matchRoute(path) {
|
|
20
|
+
this.refreshCatalog();
|
|
21
|
+
return matchStreamRoute(this.catalog, path);
|
|
22
|
+
}
|
|
23
|
+
onUpgrade(ws, ctx) {
|
|
24
|
+
const connId = `s${String(++this.connSeq)}`;
|
|
25
|
+
const q = ctx.url.indexOf('?');
|
|
26
|
+
const path = q >= 0 ? ctx.url.slice(0, q) : ctx.url;
|
|
27
|
+
const session = new StreamWsSession(this.host, connId, ctx.authority, path, {
|
|
28
|
+
send: (frame) => {
|
|
29
|
+
ws.send(frame, true);
|
|
30
|
+
},
|
|
31
|
+
close: (code) => {
|
|
32
|
+
ws.close(code);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!session.onOpen())
|
|
36
|
+
return;
|
|
37
|
+
ws.on('message', (message) => {
|
|
38
|
+
session.onMessage(message);
|
|
39
|
+
});
|
|
40
|
+
ws.on('close', () => {
|
|
41
|
+
session.onClose();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
refreshCatalog() {
|
|
45
|
+
let mtimeMs;
|
|
46
|
+
try {
|
|
47
|
+
mtimeMs = fs.statSync(this.streamWasmPath).mtimeMs;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
this.catalog = new Map();
|
|
51
|
+
this.catalogMtimeMs = -1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (mtimeMs === this.catalogMtimeMs && this.catalog.size > 0)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
this.catalog = parseStreamCatalog(fs.readFileSync(this.streamWasmPath));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
this.catalog = new Map();
|
|
61
|
+
}
|
|
62
|
+
this.catalogMtimeMs = mtimeMs;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Server } from '@dacely/hyper-express';
|
|
2
|
+
import { type ViteTarget } from '../http/proxy.js';
|
|
3
|
+
import { type StreamRouter } from './router.js';
|
|
4
|
+
export declare function streamEmulationEnabled(nodeMode: string): boolean;
|
|
5
|
+
export declare function wireStreams(app: Server, vite: ViteTarget, router: StreamRouter): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { pipeToVite } from '../http/proxy.js';
|
|
2
|
+
export function streamEmulationEnabled(nodeMode) {
|
|
3
|
+
return nodeMode === 'regional' || nodeMode === 'continental' || nodeMode === 'all';
|
|
4
|
+
}
|
|
5
|
+
export function wireStreams(app, vite, router) {
|
|
6
|
+
app.upgrade('/*', (request, response) => {
|
|
7
|
+
const def = router.matchRoute(request.path);
|
|
8
|
+
if (def !== null) {
|
|
9
|
+
response.upgrade({
|
|
10
|
+
kind: 'stream',
|
|
11
|
+
route: def.route,
|
|
12
|
+
url: request.url,
|
|
13
|
+
authority: request.headers['host'] ?? '',
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
response.upgrade({
|
|
18
|
+
kind: 'vite',
|
|
19
|
+
url: request.url,
|
|
20
|
+
protocol: request.headers['sec-websocket-protocol'] ?? '',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
app.ws('/*', { message_type: 'Buffer', idle_timeout: 0, max_payload_length: 16 * 1024 * 1024 }, (ws) => {
|
|
25
|
+
const ctx = ws.context;
|
|
26
|
+
if (ctx.kind === 'stream') {
|
|
27
|
+
router.onUpgrade(ws, ws.context);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
pipeToVite(ws, vite, ws.context);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StreamDevHost } from './manager.js';
|
|
2
|
+
export interface StreamWsTransport {
|
|
3
|
+
send(frame: Buffer): void;
|
|
4
|
+
close(code: number): void;
|
|
5
|
+
}
|
|
6
|
+
export declare class StreamWsSession {
|
|
7
|
+
private readonly host;
|
|
8
|
+
private readonly connId;
|
|
9
|
+
private readonly authority;
|
|
10
|
+
private readonly path;
|
|
11
|
+
private readonly transport;
|
|
12
|
+
private open;
|
|
13
|
+
constructor(host: StreamDevHost, connId: string, authority: string, path: string, transport: StreamWsTransport);
|
|
14
|
+
get isOpen(): boolean;
|
|
15
|
+
onOpen(): boolean;
|
|
16
|
+
onMessage(inbound: Buffer): void;
|
|
17
|
+
onClose(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class StreamWsSession {
|
|
2
|
+
host;
|
|
3
|
+
connId;
|
|
4
|
+
authority;
|
|
5
|
+
path;
|
|
6
|
+
transport;
|
|
7
|
+
open = false;
|
|
8
|
+
constructor(host, connId, authority, path, transport) {
|
|
9
|
+
this.host = host;
|
|
10
|
+
this.connId = connId;
|
|
11
|
+
this.authority = authority;
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.transport = transport;
|
|
14
|
+
}
|
|
15
|
+
get isOpen() {
|
|
16
|
+
return this.open;
|
|
17
|
+
}
|
|
18
|
+
onOpen() {
|
|
19
|
+
const up = this.host.acceptUpgrade(this.connId, this.authority, this.path);
|
|
20
|
+
if (up.kind === 'rejected') {
|
|
21
|
+
this.transport.close(up.code);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
this.open = true;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
onMessage(inbound) {
|
|
28
|
+
if (!this.open)
|
|
29
|
+
return;
|
|
30
|
+
const r = this.host.dispatch(this.connId, inbound);
|
|
31
|
+
if (r.kind === 'reply') {
|
|
32
|
+
for (const frame of r.frames)
|
|
33
|
+
this.transport.send(frame);
|
|
34
|
+
}
|
|
35
|
+
else if (r.kind === 'close') {
|
|
36
|
+
this.open = false;
|
|
37
|
+
this.transport.close(r.code);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
onClose() {
|
|
41
|
+
if (!this.open && !this.host.has(this.connId))
|
|
42
|
+
return;
|
|
43
|
+
this.open = false;
|
|
44
|
+
this.host.close(this.connId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -15,4 +15,4 @@ export interface Surface {
|
|
|
15
15
|
readonly dataCoherenceHash: number;
|
|
16
16
|
readonly pairCoherenceHash: number;
|
|
17
17
|
}
|
|
18
|
-
export declare function parseSurface(wasm: Buffer): Surface | '
|
|
18
|
+
export declare function parseSurface(wasm: Buffer): Surface | 'invalid';
|
package/docs/cli.md
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,
|
|
9
9
|
regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds
|
|
10
10
|
only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.
|
|
11
|
-
- `toiljs start`, self-host the built app
|
|
11
|
+
- `toiljs start`, self-host the built app with production hyper-express/uWS static workers,
|
|
12
|
+
SSR/wasm dispatch, daemon support, and a `/_toil` WebSocket channel. Use `--threads <n>`
|
|
13
|
+
(or `server.threads`) to set the worker count; `1` disables the pool.
|
|
12
14
|
- `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).
|
|
13
15
|
- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC
|
|
14
16
|
setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the
|
package/docs/getting-started.md
CHANGED
|
@@ -95,13 +95,13 @@ store reached through a host binding.
|
|
|
95
95
|
|
|
96
96
|
The `toiljs` CLI drives both halves:
|
|
97
97
|
|
|
98
|
-
| Command
|
|
99
|
-
|
|
|
100
|
-
| `toiljs create [name]` | Scaffold a new app (templates, styling, options).
|
|
101
|
-
| `toiljs dev`
|
|
102
|
-
| `toiljs build`
|
|
103
|
-
| `toiljs start`
|
|
104
|
-
| `toiljs doctor`
|
|
98
|
+
| Command | What it does |
|
|
99
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
100
|
+
| `toiljs create [name]` | Scaffold a new app (templates, styling, options). |
|
|
101
|
+
| `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`. |
|
|
102
|
+
| `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). |
|
|
103
|
+
| `toiljs start` | Self-host a built app with production uWS/static workers, no Vite. Flags: `--root`, `--port`, `--host`, `--threads`. |
|
|
104
|
+
| `toiljs doctor` | Diagnose setup/deps (`--json`, `--fix`). |
|
|
105
105
|
|
|
106
106
|
In dev, requests whose method matches a dispatchable verb go into the wasm
|
|
107
107
|
first; if the guest reports "no route matched" (the `x-toil-unhandled` marker)
|
package/docs/tiers.md
CHANGED
|
@@ -8,11 +8,11 @@ tier purely by adding its entry file and surface decorator; nothing else changes
|
|
|
8
8
|
|
|
9
9
|
## The tiers
|
|
10
10
|
|
|
11
|
-
| Entry (`server/`) | Surface
|
|
12
|
-
|
|
|
13
|
-
| `main.ts`
|
|
14
|
-
| `main.stream.ts`
|
|
15
|
-
| `main.daemon.ts`
|
|
11
|
+
| Entry (`server/`) | Surface | Artifact | Tier | Lifetime / placement |
|
|
12
|
+
| ----------------- | -------------------------------- | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `main.ts` | `@rest` / `@service` / `@remote` | `build/server/release.wasm` | **L1** request | A fresh handler per request, anywhere on the edge. |
|
|
14
|
+
| `main.stream.ts` | `@stream` | `build/server/release-stream.wasm` | **L2/L3** stream | One resident box per connection, pinned to a worker via QUIC connection-id steering; its state survives every event. See [Streams](./streams.md). |
|
|
15
|
+
| `main.daemon.ts` | `@daemon` / `@scheduled` | `build/server/release-cold.wasm` | **L4** daemon | Exactly one leader-elected box per domain (warm standby, at-most-once failover) firing `@scheduled` tasks. See [Daemon](./daemon.md). |
|
|
16
16
|
|
|
17
17
|
The three tiers differ in how long a box lives and how many of it exist:
|
|
18
18
|
|
|
@@ -79,7 +79,7 @@ build/server/release-cold.wasm # L4 daemon (exports: daemon_start, sche
|
|
|
79
79
|
|
|
80
80
|
## Single-artifact default
|
|
81
81
|
|
|
82
|
-
A project with no `@stream` and no `@daemon` surface keeps the
|
|
82
|
+
A project with no `@stream` and no `@daemon` surface keeps the default
|
|
83
83
|
single-artifact build - just `build/server/release.wasm`. The stream and daemon
|
|
84
84
|
tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
|
|
85
85
|
`release-stream.wasm`, add `main.daemon.ts` (and a `@daemon` class) to get
|
|
@@ -102,9 +102,15 @@ tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
|
|
|
102
102
|
class Echo {
|
|
103
103
|
private count: i32 = 0;
|
|
104
104
|
|
|
105
|
-
@connect onConnect(): void {
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
@connect onConnect(): void {
|
|
106
|
+
this.count = 0;
|
|
107
|
+
}
|
|
108
|
+
@message onMessage(): void {
|
|
109
|
+
this.count = this.count + 1;
|
|
110
|
+
}
|
|
111
|
+
@close onClose(): void {
|
|
112
|
+
/* box torn down after this hook */
|
|
113
|
+
}
|
|
108
114
|
}
|
|
109
115
|
```
|
|
110
116
|
|
|
@@ -13,6 +13,12 @@ import { NewMessage } from '../models/NewMessage';
|
|
|
13
13
|
*
|
|
14
14
|
* await Server.REST.guestbook.sign({ body: new NewMessage('Ada', 'hi!') });
|
|
15
15
|
* const book = await Server.REST.guestbook.list(); // { total, entries: [...] }
|
|
16
|
+
*
|
|
17
|
+
* Reading the newest entries is a SCAN (`events.latest`), which is barred in a
|
|
18
|
+
* request handler (a `@get` runs as a Query, a `@post` as an Action) because a
|
|
19
|
+
* scan can fan out across unbounded rows. So a `@derive` does the scan off the
|
|
20
|
+
* request path and `publish`es a materialized `GuestbookView`; the GET then
|
|
21
|
+
* serves that view with a single non-scan `view.get`.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
24
|
// The guestbook is one global stream; a single fixed key addresses it.
|
|
@@ -28,33 +34,52 @@ class GuestKey {
|
|
|
28
34
|
class GuestbookDb {
|
|
29
35
|
@collection static entries: Events<GuestKey, GuestEntry>;
|
|
30
36
|
@collection static totals: Counter<GuestKey>;
|
|
31
|
-
|
|
37
|
+
// The materialized snapshot the GET serves: total + newest entries.
|
|
38
|
+
@collection static book: View<GuestKey, GuestbookView>;
|
|
32
39
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Recompute the materialized view from the source of truth (the event log +
|
|
42
|
+
* the counter). The runtime runs this under FunctionKind=Derive after a
|
|
43
|
+
* signature is appended (and rebuilds it when a box first loads), so the
|
|
44
|
+
* scan (`events.latest`) and the `view.publish` - both barred in a request
|
|
45
|
+
* handler - are allowed here. This is also where `GuestEntry`'s `@migrate`
|
|
46
|
+
* fires: `events.latest` decodes each stored event at ITS schema version, so
|
|
47
|
+
* an old pre-`at` entry is migrated as the view is rebuilt.
|
|
48
|
+
*/
|
|
49
|
+
@derive
|
|
50
|
+
recompute(): void {
|
|
51
|
+
const key = new GuestKey('main');
|
|
52
|
+
const view = new GuestbookView();
|
|
53
|
+
view.total = GuestbookDb.totals.get(key);
|
|
54
|
+
view.entries = GuestbookDb.entries.latest(key, 10);
|
|
55
|
+
GuestbookDb.book.publish(key, view);
|
|
56
|
+
}
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
@rest('guestbook')
|
|
43
60
|
class Guestbook {
|
|
44
|
-
/** `GET /guestbook` - the running total + the most recent signatures
|
|
61
|
+
/** `GET /guestbook` - the running total + the most recent signatures, served
|
|
62
|
+
* from the materialized view (a non-scan `view.get`). */
|
|
45
63
|
@get('/')
|
|
46
64
|
public list(): GuestbookView {
|
|
47
|
-
|
|
65
|
+
const key = new GuestKey('main');
|
|
66
|
+
const view = GuestbookDb.book.get(key);
|
|
67
|
+
if (view == null) return new GuestbookView();
|
|
68
|
+
return view;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
/** `POST /guestbook` - append a signature (PERSISTED) and
|
|
51
|
-
*
|
|
71
|
+
/** `POST /guestbook` - append a signature (PERSISTED) and acknowledge with
|
|
72
|
+
* the new running total. The entries list is served by the GET above from
|
|
73
|
+
* the view the `@derive` republishes right after this action. Sign twice
|
|
74
|
+
* and the total keeps climbing across requests. */
|
|
52
75
|
@post('/')
|
|
53
76
|
public sign(input: NewMessage): GuestbookView {
|
|
54
77
|
const key = new GuestKey('main');
|
|
55
78
|
const at = <u64>(Date.now() / 1000);
|
|
56
79
|
GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, at));
|
|
57
80
|
GuestbookDb.totals.add(key, 1);
|
|
58
|
-
|
|
81
|
+
const view = new GuestbookView();
|
|
82
|
+
view.total = GuestbookDb.totals.get(key); // Counter get: non-scan, action-legal
|
|
83
|
+
return view;
|
|
59
84
|
}
|
|
60
85
|
}
|
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() {
|