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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { parseSurface } from '../wasm/surface.js';
|
|
2
|
+
import { buildStreamImports, freshStreamBoxState } from './host.js';
|
|
3
|
+
const EVENT_CONNECT = 1;
|
|
4
|
+
const EVENT_MESSAGE = 2;
|
|
5
|
+
const EVENT_CLOSE = 3;
|
|
6
|
+
const EVENT_DISCONNECT = 4;
|
|
7
|
+
const RING_CTRL_BYTES = 32;
|
|
8
|
+
const RING_FRAME_HEADER = 12;
|
|
9
|
+
const RING_MAGIC = 0x3147_4e52;
|
|
10
|
+
const RING_VERSION = 1;
|
|
11
|
+
const RC_WRITE = 12;
|
|
12
|
+
const RC_READ = 16;
|
|
13
|
+
const FRAME_TYPE_DATA_RELIABLE = 1;
|
|
14
|
+
const MAX_STREAM_FRAME_LEN = 65536;
|
|
15
|
+
const SI_TRANSPORT = 8;
|
|
16
|
+
const SI_AUTH_LEN = 10;
|
|
17
|
+
const SI_PATH_LEN = 12;
|
|
18
|
+
const SI_RESERVED2 = 14;
|
|
19
|
+
const SI_BODY = 16;
|
|
20
|
+
const SI_TRANSPORT_WEBTRANSPORT = 1;
|
|
21
|
+
export function decodeRejectCode(rc) {
|
|
22
|
+
const raw = Number((-rc - 0x10000n) & 0xffffn);
|
|
23
|
+
return raw >= 0x0200 && raw <= 0x02ff ? raw : 0x0208;
|
|
24
|
+
}
|
|
25
|
+
export class DevStreamBox {
|
|
26
|
+
exports;
|
|
27
|
+
_state;
|
|
28
|
+
rings;
|
|
29
|
+
streamInfo;
|
|
30
|
+
constructor(exports, _state, rings, streamInfo) {
|
|
31
|
+
this.exports = exports;
|
|
32
|
+
this._state = _state;
|
|
33
|
+
this.rings = rings;
|
|
34
|
+
this.streamInfo = streamInfo;
|
|
35
|
+
}
|
|
36
|
+
static load(wasm) {
|
|
37
|
+
const surface = parseSurface(wasm);
|
|
38
|
+
if (surface === 'invalid' || surface.targetMode !== 'hot') {
|
|
39
|
+
throw new Error('stream box requires a hot artifact with a valid toil.surface');
|
|
40
|
+
}
|
|
41
|
+
const ref = { memory: null };
|
|
42
|
+
const state = freshStreamBoxState();
|
|
43
|
+
const module = new WebAssembly.Module(new Uint8Array(wasm));
|
|
44
|
+
const instance = new WebAssembly.Instance(module, buildStreamImports(ref, state));
|
|
45
|
+
const exports = instance.exports;
|
|
46
|
+
if (typeof exports.stream_dispatch !== 'function' ||
|
|
47
|
+
!(exports.memory instanceof WebAssembly.Memory)) {
|
|
48
|
+
throw new Error("stream artifact must export 'stream_dispatch' + 'memory'");
|
|
49
|
+
}
|
|
50
|
+
ref.memory = exports.memory;
|
|
51
|
+
const rings = DevStreamBox.resolveRings(exports);
|
|
52
|
+
const streamInfo = DevStreamBox.resolveStreamInfo(exports);
|
|
53
|
+
const box = new DevStreamBox(exports, state, rings, streamInfo);
|
|
54
|
+
if (rings)
|
|
55
|
+
box.stampRings();
|
|
56
|
+
return box;
|
|
57
|
+
}
|
|
58
|
+
get hasRings() {
|
|
59
|
+
return this.rings !== null;
|
|
60
|
+
}
|
|
61
|
+
get hasConnectBridge() {
|
|
62
|
+
return this.streamInfo !== null;
|
|
63
|
+
}
|
|
64
|
+
onConnect(streamId, authority, path) {
|
|
65
|
+
this.writeConnectInfo(streamId, authority, path);
|
|
66
|
+
const rc = this.dispatch(EVENT_CONNECT, streamId);
|
|
67
|
+
if (rc < 0n)
|
|
68
|
+
return { kind: 'reject', code: decodeRejectCode(rc) };
|
|
69
|
+
this.resetEgressRing();
|
|
70
|
+
return { kind: 'accept' };
|
|
71
|
+
}
|
|
72
|
+
onClose(streamId) {
|
|
73
|
+
return this.dispatch(EVENT_CLOSE, streamId);
|
|
74
|
+
}
|
|
75
|
+
onDisconnect(streamId) {
|
|
76
|
+
return this.dispatch(EVENT_DISCONNECT, streamId);
|
|
77
|
+
}
|
|
78
|
+
onMessage(streamId, inbound) {
|
|
79
|
+
if (!this.rings) {
|
|
80
|
+
throw new Error('stream box has no ring runtime; the message bridge is unavailable');
|
|
81
|
+
}
|
|
82
|
+
this.ingressWrite(inbound);
|
|
83
|
+
const ret = this.dispatch(EVENT_MESSAGE, streamId);
|
|
84
|
+
if (ret < 0n)
|
|
85
|
+
return { kind: 'reject', code: decodeRejectCode(ret) };
|
|
86
|
+
return { kind: 'reply', frames: this.egressDrain() };
|
|
87
|
+
}
|
|
88
|
+
dispatch(eventKind, streamId) {
|
|
89
|
+
const lo = Number(streamId & 0xffffffffn) | 0;
|
|
90
|
+
const hi = Number((streamId >> 32n) & 0xffffffffn) | 0;
|
|
91
|
+
return this.exports.stream_dispatch(eventKind, lo, hi);
|
|
92
|
+
}
|
|
93
|
+
static resolveRings(e) {
|
|
94
|
+
if (typeof e.stream_ring_offset !== 'function' ||
|
|
95
|
+
typeof e.stream_ring_capacity !== 'function' ||
|
|
96
|
+
typeof e.stream_egress_offset !== 'function' ||
|
|
97
|
+
typeof e.stream_egress_capacity !== 'function') {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ingressOff: e.stream_ring_offset() >>> 0,
|
|
102
|
+
ingressCap: e.stream_ring_capacity() >>> 0,
|
|
103
|
+
egressOff: e.stream_egress_offset() >>> 0,
|
|
104
|
+
egressCap: e.stream_egress_capacity() >>> 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
static resolveStreamInfo(e) {
|
|
108
|
+
if (typeof e.stream_info_offset !== 'function' || typeof e.stream_info_capacity !== 'function') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return { offset: e.stream_info_offset() >>> 0, cap: e.stream_info_capacity() >>> 0 };
|
|
112
|
+
}
|
|
113
|
+
stampRings() {
|
|
114
|
+
const rings = this.rings;
|
|
115
|
+
if (!rings)
|
|
116
|
+
return;
|
|
117
|
+
this.stampOne(rings.ingressOff, rings.ingressCap);
|
|
118
|
+
this.stampOne(rings.egressOff, rings.egressCap);
|
|
119
|
+
}
|
|
120
|
+
stampOne(base, cap) {
|
|
121
|
+
const dv = new DataView(this.exports.memory.buffer);
|
|
122
|
+
dv.setUint32(base + 0, RING_MAGIC, true);
|
|
123
|
+
dv.setUint16(base + 4, RING_VERSION, true);
|
|
124
|
+
dv.setUint16(base + 6, 0, true);
|
|
125
|
+
dv.setUint32(base + 8, cap, true);
|
|
126
|
+
dv.setUint32(base + RC_WRITE, 0, true);
|
|
127
|
+
dv.setUint32(base + RC_READ, 0, true);
|
|
128
|
+
}
|
|
129
|
+
writeConnectInfo(streamId, authority, path) {
|
|
130
|
+
const info = this.streamInfo;
|
|
131
|
+
if (!info)
|
|
132
|
+
return;
|
|
133
|
+
const base = info.offset;
|
|
134
|
+
const body = Math.max(0, info.cap - SI_BODY);
|
|
135
|
+
const authBytes = Buffer.from(authority, 'utf8');
|
|
136
|
+
const authLen = Math.min(authBytes.length, 0xffff, body);
|
|
137
|
+
const pathBytes = Buffer.from(path, 'utf8');
|
|
138
|
+
const pathLen = Math.min(pathBytes.length, 0xffff, body - authLen);
|
|
139
|
+
const dv = new DataView(this.exports.memory.buffer);
|
|
140
|
+
dv.setBigUint64(base + 0, streamId, true);
|
|
141
|
+
dv.setUint8(base + SI_TRANSPORT, SI_TRANSPORT_WEBTRANSPORT);
|
|
142
|
+
dv.setUint8(base + SI_TRANSPORT + 1, 0);
|
|
143
|
+
dv.setUint16(base + SI_AUTH_LEN, authLen, true);
|
|
144
|
+
dv.setUint16(base + SI_PATH_LEN, pathLen, true);
|
|
145
|
+
dv.setUint16(base + SI_RESERVED2, 0, true);
|
|
146
|
+
const memU8 = new Uint8Array(this.exports.memory.buffer);
|
|
147
|
+
if (authLen > 0)
|
|
148
|
+
memU8.set(authBytes.subarray(0, authLen), base + SI_BODY);
|
|
149
|
+
if (pathLen > 0)
|
|
150
|
+
memU8.set(pathBytes.subarray(0, pathLen), base + SI_BODY + authLen);
|
|
151
|
+
}
|
|
152
|
+
resetEgressRing() {
|
|
153
|
+
const rings = this.rings;
|
|
154
|
+
if (!rings)
|
|
155
|
+
return;
|
|
156
|
+
const dv = new DataView(this.exports.memory.buffer);
|
|
157
|
+
dv.setUint32(rings.egressOff + RC_WRITE, 0, true);
|
|
158
|
+
dv.setUint32(rings.egressOff + RC_READ, 0, true);
|
|
159
|
+
}
|
|
160
|
+
ingressWrite(inbound) {
|
|
161
|
+
const rings = this.rings;
|
|
162
|
+
if (!rings)
|
|
163
|
+
throw new Error('ingressWrite: no ring runtime');
|
|
164
|
+
const { ingressOff: base, ingressCap: cap } = rings;
|
|
165
|
+
const dv = new DataView(this.exports.memory.buffer);
|
|
166
|
+
const n = inbound.length;
|
|
167
|
+
const frameLen = RING_FRAME_HEADER + n;
|
|
168
|
+
if (frameLen > cap) {
|
|
169
|
+
throw new Error(`inbound frame (${String(frameLen)} B) exceeds ingress capacity`);
|
|
170
|
+
}
|
|
171
|
+
const w0 = dv.getUint32(base + RC_WRITE, true);
|
|
172
|
+
const r0 = dv.getUint32(base + RC_READ, true);
|
|
173
|
+
let w;
|
|
174
|
+
if (r0 === w0) {
|
|
175
|
+
dv.setUint32(base + RC_WRITE, 0, true);
|
|
176
|
+
dv.setUint32(base + RC_READ, 0, true);
|
|
177
|
+
w = 0;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
w = w0;
|
|
181
|
+
}
|
|
182
|
+
if (w + frameLen > cap)
|
|
183
|
+
throw new Error('ingress frame would not fit (v1 is no-wrap)');
|
|
184
|
+
const f = base + RING_CTRL_BYTES + w;
|
|
185
|
+
dv.setUint8(f + 0, RING_VERSION);
|
|
186
|
+
dv.setUint8(f + 1, FRAME_TYPE_DATA_RELIABLE);
|
|
187
|
+
dv.setUint16(f + 2, 0, true);
|
|
188
|
+
dv.setUint32(f + 4, n, true);
|
|
189
|
+
dv.setUint32(f + 8, 0, true);
|
|
190
|
+
if (n > 0)
|
|
191
|
+
new Uint8Array(this.exports.memory.buffer, f + RING_FRAME_HEADER, n).set(inbound);
|
|
192
|
+
dv.setUint32(base + RC_WRITE, w + frameLen, true);
|
|
193
|
+
}
|
|
194
|
+
egressDrain() {
|
|
195
|
+
const rings = this.rings;
|
|
196
|
+
if (!rings)
|
|
197
|
+
return [];
|
|
198
|
+
const { egressOff: base, egressCap: cap } = rings;
|
|
199
|
+
const dv = new DataView(this.exports.memory.buffer);
|
|
200
|
+
const w = dv.getUint32(base + RC_WRITE, true);
|
|
201
|
+
let r = dv.getUint32(base + RC_READ, true);
|
|
202
|
+
const frames = [];
|
|
203
|
+
while (r < w) {
|
|
204
|
+
const f = base + RING_CTRL_BYTES + r;
|
|
205
|
+
if (r + RING_FRAME_HEADER > cap)
|
|
206
|
+
break;
|
|
207
|
+
const len = dv.getUint32(f + 4, true);
|
|
208
|
+
if (len > MAX_STREAM_FRAME_LEN)
|
|
209
|
+
break;
|
|
210
|
+
const span = RING_FRAME_HEADER + len;
|
|
211
|
+
if (r + span > cap)
|
|
212
|
+
break;
|
|
213
|
+
const payloadOff = f + RING_FRAME_HEADER;
|
|
214
|
+
frames.push(Buffer.from(new Uint8Array(this.exports.memory.buffer, payloadOff, len)));
|
|
215
|
+
r += span;
|
|
216
|
+
}
|
|
217
|
+
dv.setUint32(base + RC_READ, r, true);
|
|
218
|
+
return frames;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const STREAM_REJECTED = 520;
|
|
2
|
+
export declare const STREAM_HOOK_TRAPPED = 512;
|
|
3
|
+
export type StreamUpgradeOutcome = {
|
|
4
|
+
readonly kind: 'accepted';
|
|
5
|
+
readonly streamId: bigint;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'rejected';
|
|
8
|
+
readonly code: number;
|
|
9
|
+
};
|
|
10
|
+
export type StreamDispatchResult = {
|
|
11
|
+
readonly kind: 'reply';
|
|
12
|
+
readonly frames: Buffer[];
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'close';
|
|
15
|
+
readonly code: number;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'noConnection';
|
|
18
|
+
};
|
|
19
|
+
export declare class StreamDevHost {
|
|
20
|
+
private readonly streamWasmPath;
|
|
21
|
+
private bytes;
|
|
22
|
+
private loadedMtimeMs;
|
|
23
|
+
private readonly conns;
|
|
24
|
+
private nextStreamId;
|
|
25
|
+
constructor(streamWasmPath: string);
|
|
26
|
+
get activeConnections(): number;
|
|
27
|
+
has(connId: string): boolean;
|
|
28
|
+
acceptUpgrade(connId: string, authority: string, path: string): StreamUpgradeOutcome;
|
|
29
|
+
dispatch(connId: string, inbound: Buffer): StreamDispatchResult;
|
|
30
|
+
close(connId: string): void;
|
|
31
|
+
disconnect(connId: string): void;
|
|
32
|
+
private refresh;
|
|
33
|
+
private allocStreamId;
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|
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)
|
|
@@ -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
|
}
|