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,121 @@
|
|
|
1
|
+
import { customSection } from '../wasm/sections.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses the `toildb.derives` wiring section emitted by toilscript for a
|
|
5
|
+
* `@database` class with `@derive` materializer methods (see the compiler's
|
|
6
|
+
* `buildToilDbDerives`). It maps each derive to its owning `@database` class, so
|
|
7
|
+
* the dev runtime (`runtime/module.ts`) can, after a dispatch writes a source
|
|
8
|
+
* collection, re-run that database's derives under FunctionKind=Derive. Fails
|
|
9
|
+
* closed: any malformed byte yields `[]` (no derive runs) rather than throwing.
|
|
10
|
+
*
|
|
11
|
+
* Section layout (LE), mirroring `buildToilDbDerives`:
|
|
12
|
+
* u16 format_version = 1
|
|
13
|
+
* u16 n_derives
|
|
14
|
+
* per derive: u16 derive_id, str db_name, str method_name (str = u32 len + bytes)
|
|
15
|
+
*/
|
|
16
|
+
export interface DeriveEntry {
|
|
17
|
+
readonly deriveId: number;
|
|
18
|
+
readonly dbName: string;
|
|
19
|
+
readonly methodName: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SECTION = 'toildb.derives';
|
|
23
|
+
const VERSION = 1;
|
|
24
|
+
const MAX_SECTION_BYTES = 128 * 1024;
|
|
25
|
+
const MAX_DERIVES = 1024;
|
|
26
|
+
const MAX_NAME_BYTES = 1024;
|
|
27
|
+
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
28
|
+
|
|
29
|
+
export function parseDerives(wasm: Buffer): readonly DeriveEntry[] {
|
|
30
|
+
let section: Buffer | null;
|
|
31
|
+
try {
|
|
32
|
+
section = customSection(wasm, SECTION);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
if (section === null) return [];
|
|
37
|
+
if (section.length > MAX_SECTION_BYTES) return [];
|
|
38
|
+
|
|
39
|
+
const r = new Reader(section);
|
|
40
|
+
const version = r.u16();
|
|
41
|
+
if (!r.ok || version !== VERSION) return [];
|
|
42
|
+
const count = r.u16();
|
|
43
|
+
if (!r.ok || count > MAX_DERIVES) return [];
|
|
44
|
+
|
|
45
|
+
const derives: DeriveEntry[] = [];
|
|
46
|
+
for (let i = 0; i < count && r.ok; i++) {
|
|
47
|
+
const deriveId = r.u16();
|
|
48
|
+
const dbName = r.string();
|
|
49
|
+
const methodName = r.string();
|
|
50
|
+
if (!r.ok || dbName.length === 0) return [];
|
|
51
|
+
derives.push({ deriveId, dbName, methodName });
|
|
52
|
+
}
|
|
53
|
+
if (!r.ok || r.remaining() !== 0) return [];
|
|
54
|
+
return derives;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The derives whose owning `@database` had at least one source collection
|
|
59
|
+
* written during this dispatch. `written` holds "Db/coll" store keys; the
|
|
60
|
+
* database is the prefix before the first `/`. Each affected derive appears at
|
|
61
|
+
* most once (coalescing: many writes to one database run its derives once).
|
|
62
|
+
*/
|
|
63
|
+
export function derivesForWrites(
|
|
64
|
+
derives: readonly DeriveEntry[],
|
|
65
|
+
written: ReadonlySet<string>,
|
|
66
|
+
): readonly DeriveEntry[] {
|
|
67
|
+
if (derives.length === 0 || written.size === 0) return [];
|
|
68
|
+
const dbs = new Set<string>();
|
|
69
|
+
for (const key of written) {
|
|
70
|
+
const slash = key.indexOf('/');
|
|
71
|
+
dbs.add(slash >= 0 ? key.slice(0, slash) : key);
|
|
72
|
+
}
|
|
73
|
+
return derives.filter((d) => dbs.has(d.dbName));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class Reader {
|
|
77
|
+
private pos = 0;
|
|
78
|
+
ok = true;
|
|
79
|
+
|
|
80
|
+
constructor(private readonly bytes: Buffer) {}
|
|
81
|
+
|
|
82
|
+
remaining(): number {
|
|
83
|
+
return this.bytes.length - this.pos;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
u16(): number {
|
|
87
|
+
if (!this.ok || this.pos + 2 > this.bytes.length) {
|
|
88
|
+
this.ok = false;
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const out = this.bytes.readUInt16LE(this.pos);
|
|
92
|
+
this.pos += 2;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
u32(): number {
|
|
97
|
+
if (!this.ok || this.pos + 4 > this.bytes.length) {
|
|
98
|
+
this.ok = false;
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
const out = this.bytes.readUInt32LE(this.pos);
|
|
102
|
+
this.pos += 4;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
string(): string {
|
|
107
|
+
const len = this.u32();
|
|
108
|
+
if (!this.ok || len > MAX_NAME_BYTES || this.pos + len > this.bytes.length) {
|
|
109
|
+
this.ok = false;
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const out = UTF8_DECODER.decode(this.bytes.subarray(this.pos, this.pos + len));
|
|
114
|
+
this.pos += len;
|
|
115
|
+
return out;
|
|
116
|
+
} catch {
|
|
117
|
+
this.ok = false;
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -15,4 +15,5 @@ export {
|
|
|
15
15
|
setDbCatalog,
|
|
16
16
|
} from './database.js';
|
|
17
17
|
export { parseCatalog } from './catalog.js';
|
|
18
|
+
export { type DeriveEntry, derivesForWrites, parseDerives } from './derives.js';
|
|
18
19
|
export { CollectionFamily, DbFunctionKind, type DbDevState, freshDbState } from './types.js';
|
|
@@ -50,6 +50,11 @@ export interface DbDevState {
|
|
|
50
50
|
lastResult: Buffer | null;
|
|
51
51
|
lastResultVersion: number;
|
|
52
52
|
functionKind: DbFunctionKind;
|
|
53
|
+
/** Names ("Db/coll") of source collections written during this dispatch, so
|
|
54
|
+
* the runtime can re-run the affected `@derive` materializers afterward.
|
|
55
|
+
* Only populated for non-Derive dispatches (a derive's own writes must not
|
|
56
|
+
* re-trigger it - see `database.ts` `recordWrite`). */
|
|
57
|
+
writtenCollections: Set<string>;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export function freshDbState(): DbDevState {
|
|
@@ -58,6 +63,7 @@ export function freshDbState(): DbDevState {
|
|
|
58
63
|
lastResult: null,
|
|
59
64
|
lastResultVersion: -1,
|
|
60
65
|
functionKind: DbFunctionKind.Job,
|
|
66
|
+
writtenCollections: new Set<string>(),
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -114,45 +114,59 @@ export function wireWebsocketProxy(app: Server, target: ViteTarget): void {
|
|
|
114
114
|
'/*',
|
|
115
115
|
{ message_type: 'Buffer', idle_timeout: 120, max_payload_length: 16 * 1024 * 1024 },
|
|
116
116
|
(ws: Websocket) => {
|
|
117
|
-
|
|
118
|
-
const upstream = new WebSocket(
|
|
119
|
-
`ws://${target.host}:${String(target.port)}${url}`,
|
|
120
|
-
protocol ? protocol.split(',').map((p) => p.trim()) : [],
|
|
121
|
-
);
|
|
122
|
-
upstream.binaryType = 'arraybuffer';
|
|
123
|
-
|
|
124
|
-
const pending: (string | Uint8Array<ArrayBuffer>)[] = [];
|
|
125
|
-
let open = false;
|
|
126
|
-
|
|
127
|
-
upstream.onopen = (): void => {
|
|
128
|
-
open = true;
|
|
129
|
-
for (const m of pending) upstream.send(m);
|
|
130
|
-
pending.length = 0;
|
|
131
|
-
};
|
|
132
|
-
upstream.onmessage = (event: MessageEvent): void => {
|
|
133
|
-
if (typeof event.data === 'string') ws.send(event.data);
|
|
134
|
-
else ws.send(Buffer.from(event.data as ArrayBuffer), true);
|
|
135
|
-
};
|
|
136
|
-
upstream.onclose = (event: CloseEvent): void => {
|
|
137
|
-
ws.close(event.code, event.reason);
|
|
138
|
-
};
|
|
139
|
-
upstream.onerror = (): void => {
|
|
140
|
-
ws.close();
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
ws.on('message', (message: Buffer, isBinary: boolean) => {
|
|
144
|
-
const m = toUpstreamMessage(message, isBinary);
|
|
145
|
-
if (open) upstream.send(m);
|
|
146
|
-
else pending.push(m);
|
|
147
|
-
});
|
|
148
|
-
ws.on('close', () => {
|
|
149
|
-
if (
|
|
150
|
-
upstream.readyState === WebSocket.OPEN ||
|
|
151
|
-
upstream.readyState === WebSocket.CONNECTING
|
|
152
|
-
) {
|
|
153
|
-
upstream.close();
|
|
154
|
-
}
|
|
155
|
-
});
|
|
117
|
+
pipeToVite(ws, target, ws.context as { url: string; protocol: string });
|
|
156
118
|
},
|
|
157
119
|
);
|
|
158
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pipe ONE upgraded websocket to the internal Vite HMR server. The verbatim body extracted from
|
|
124
|
+
* {@link wireWebsocketProxy} so the dev STREAM router (doc 08 4.1 `wireStreams`) can reuse it for every
|
|
125
|
+
* NON-stream upgrade while it handles `@stream`-route upgrades itself - HMR stays byte-for-byte
|
|
126
|
+
* unchanged. `ctx` is the upgrade context (`{ url, protocol }`) the upgrade handler stamped.
|
|
127
|
+
*/
|
|
128
|
+
export function pipeToVite(
|
|
129
|
+
ws: Websocket,
|
|
130
|
+
target: ViteTarget,
|
|
131
|
+
ctx: { url: string; protocol: string },
|
|
132
|
+
): void {
|
|
133
|
+
const { url, protocol } = ctx;
|
|
134
|
+
const upstream = new WebSocket(
|
|
135
|
+
`ws://${target.host}:${String(target.port)}${url}`,
|
|
136
|
+
protocol ? protocol.split(',').map((p) => p.trim()) : [],
|
|
137
|
+
);
|
|
138
|
+
upstream.binaryType = 'arraybuffer';
|
|
139
|
+
|
|
140
|
+
const pending: (string | Uint8Array<ArrayBuffer>)[] = [];
|
|
141
|
+
let open = false;
|
|
142
|
+
|
|
143
|
+
upstream.onopen = (): void => {
|
|
144
|
+
open = true;
|
|
145
|
+
for (const m of pending) upstream.send(m);
|
|
146
|
+
pending.length = 0;
|
|
147
|
+
};
|
|
148
|
+
upstream.onmessage = (event: MessageEvent): void => {
|
|
149
|
+
if (typeof event.data === 'string') ws.send(event.data);
|
|
150
|
+
else ws.send(Buffer.from(event.data as ArrayBuffer), true);
|
|
151
|
+
};
|
|
152
|
+
upstream.onclose = (event: CloseEvent): void => {
|
|
153
|
+
ws.close(event.code, event.reason);
|
|
154
|
+
};
|
|
155
|
+
upstream.onerror = (): void => {
|
|
156
|
+
ws.close();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
ws.on('message', (message: Buffer, isBinary: boolean) => {
|
|
160
|
+
const m = toUpstreamMessage(message, isBinary);
|
|
161
|
+
if (open) upstream.send(m);
|
|
162
|
+
else pending.push(m);
|
|
163
|
+
});
|
|
164
|
+
ws.on('close', () => {
|
|
165
|
+
if (
|
|
166
|
+
upstream.readyState === WebSocket.OPEN ||
|
|
167
|
+
upstream.readyState === WebSocket.CONNECTING
|
|
168
|
+
) {
|
|
169
|
+
upstream.close();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { type Request, type Response, type Server } from '@dacely/hyper-express';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
|
|
7
|
+
import { type EnvelopeRequest, METHOD_CODES } from './envelope.js';
|
|
8
|
+
import { applyCacheRule, lookupCache, type CacheableResult } from './cache.js';
|
|
9
|
+
import { type WasmDispatchResult, WasmServerModule } from '../runtime/module.js';
|
|
10
|
+
import { assembleSsr, type SsrResult, type SsrRoute } from '../ssr.js';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_BODY_LENGTH = 1024 * 1024 * 8;
|
|
13
|
+
export const MAX_BODY_BUFFER = 1024 * 32;
|
|
14
|
+
export const HTTP_IDLE_TIMEOUT = 60;
|
|
15
|
+
export const HTTP_RESPONSE_TIMEOUT = 120;
|
|
16
|
+
|
|
17
|
+
export const MIME: Readonly<Record<string, string>> = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
20
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.avif': 'image/avif',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.wasm': 'application/wasm',
|
|
33
|
+
'.woff2': 'font/woff2',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface RuntimeServerOptions {
|
|
37
|
+
readonly maxBodyLength?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function runtimeServerOptions(options: RuntimeServerOptions): {
|
|
41
|
+
max_body_length: number;
|
|
42
|
+
max_body_buffer: number;
|
|
43
|
+
fast_abort: true;
|
|
44
|
+
idle_timeout: number;
|
|
45
|
+
response_timeout: number;
|
|
46
|
+
} {
|
|
47
|
+
return {
|
|
48
|
+
max_body_length: options.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH,
|
|
49
|
+
max_body_buffer: MAX_BODY_BUFFER,
|
|
50
|
+
fast_abort: true,
|
|
51
|
+
idle_timeout: HTTP_IDLE_TIMEOUT,
|
|
52
|
+
response_timeout: HTTP_RESPONSE_TIMEOUT,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function installRuntimeErrorHandler(app: Server): void {
|
|
57
|
+
app.set_error_handler((_request: Request, response: Response, error: Error) => {
|
|
58
|
+
if (response.completed) return;
|
|
59
|
+
response.atomic(() => {
|
|
60
|
+
response.status(500).send(`internal error: ${error.message}\n`);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isDispatchableMethod(method: string): boolean {
|
|
66
|
+
return METHOD_CODES[method] !== undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveFileInside(root: string, file: string): string | null {
|
|
70
|
+
const resolved = path.resolve(root, file);
|
|
71
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
72
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return null;
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveStaticFile(root: string, requestPath: string): string | null {
|
|
77
|
+
let decoded: string;
|
|
78
|
+
try {
|
|
79
|
+
decoded = decodeURIComponent(requestPath);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (decoded === '/' || decoded === '') return null;
|
|
84
|
+
return resolveFileInside(root, decoded.replace(/^\/+/, ''));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PreparedHttpResponse {
|
|
88
|
+
readonly status: number;
|
|
89
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
90
|
+
readonly body: Uint8Array;
|
|
91
|
+
readonly sendfile: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function toEnvelopeRequest(request: Request): Promise<EnvelopeRequest> {
|
|
95
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
96
|
+
const body = hasBody ? new Uint8Array(await request.buffer()) : new Uint8Array(0);
|
|
97
|
+
const xff = request.headers['x-forwarded-for'];
|
|
98
|
+
const clientIp =
|
|
99
|
+
typeof xff === 'string' && xff.length > 0 ? xff.split(',')[0]!.trim() : '127.0.0.1';
|
|
100
|
+
return {
|
|
101
|
+
method: request.method,
|
|
102
|
+
path: request.url,
|
|
103
|
+
headers: Object.entries(request.headers),
|
|
104
|
+
body,
|
|
105
|
+
clientIp,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function prepareWasmResponse(
|
|
110
|
+
root: string,
|
|
111
|
+
result: Pick<CacheableResult, 'status' | 'headers' | 'body' | 'sendfile'>,
|
|
112
|
+
serverHeader: string,
|
|
113
|
+
): PreparedHttpResponse {
|
|
114
|
+
const headers: (readonly [string, string])[] = [];
|
|
115
|
+
let hasContentType = false;
|
|
116
|
+
for (const [name, value] of result.headers) {
|
|
117
|
+
if (name.toLowerCase() === 'content-type') hasContentType = true;
|
|
118
|
+
headers.push([name, value]);
|
|
119
|
+
}
|
|
120
|
+
headers.push(['server', serverHeader]);
|
|
121
|
+
|
|
122
|
+
if (result.sendfile !== null) {
|
|
123
|
+
const file = resolveFileInside(root, result.sendfile);
|
|
124
|
+
if (file === null) {
|
|
125
|
+
return {
|
|
126
|
+
status: 404,
|
|
127
|
+
headers: [
|
|
128
|
+
['server', serverHeader],
|
|
129
|
+
['content-type', 'text/plain; charset=utf-8'],
|
|
130
|
+
],
|
|
131
|
+
body: new TextEncoder().encode('not found\n'),
|
|
132
|
+
sendfile: null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!hasContentType) {
|
|
136
|
+
headers.push([
|
|
137
|
+
'content-type',
|
|
138
|
+
MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
return { status: result.status, headers, body: new Uint8Array(0), sendfile: file };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!hasContentType) headers.push(['content-type', 'text/plain; charset=utf-8']);
|
|
145
|
+
return { status: result.status, headers, body: result.body, sendfile: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function sendPreparedResponse(response: Response, out: PreparedHttpResponse): void {
|
|
149
|
+
response.status(out.status);
|
|
150
|
+
for (const [name, value] of out.headers) response.header(name, value);
|
|
151
|
+
if (out.sendfile !== null) {
|
|
152
|
+
response.sendFile(out.sendfile);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
response.send(Buffer.from(out.body.buffer, out.body.byteOffset, out.body.length));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function sendWasmResponse(
|
|
159
|
+
response: Response,
|
|
160
|
+
root: string,
|
|
161
|
+
result: Pick<CacheableResult, 'status' | 'headers' | 'body' | 'sendfile'>,
|
|
162
|
+
serverHeader: string,
|
|
163
|
+
): void {
|
|
164
|
+
sendPreparedResponse(response, prepareWasmResponse(root, result, serverHeader));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function prepareSsrResponse(
|
|
168
|
+
out: SsrResult,
|
|
169
|
+
headOnly: boolean,
|
|
170
|
+
serverHeader: string,
|
|
171
|
+
): PreparedHttpResponse {
|
|
172
|
+
const headers: (readonly [string, string])[] = [];
|
|
173
|
+
let hasContentType = false;
|
|
174
|
+
for (const [name, value] of out.headers) {
|
|
175
|
+
if (name.toLowerCase() === 'content-type') hasContentType = true;
|
|
176
|
+
headers.push([name, value]);
|
|
177
|
+
}
|
|
178
|
+
if (!hasContentType) headers.push(['content-type', 'text/html; charset=utf-8']);
|
|
179
|
+
headers.push(['server', serverHeader]);
|
|
180
|
+
return {
|
|
181
|
+
status: out.status,
|
|
182
|
+
headers,
|
|
183
|
+
body: headOnly ? new Uint8Array(0) : out.html,
|
|
184
|
+
sendfile: null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function sendSsr(
|
|
189
|
+
response: Response,
|
|
190
|
+
out: SsrResult,
|
|
191
|
+
headOnly: boolean,
|
|
192
|
+
serverHeader: string,
|
|
193
|
+
): void {
|
|
194
|
+
sendPreparedResponse(response, prepareSsrResponse(out, headOnly, serverHeader));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface WasmDispatchOutcome {
|
|
198
|
+
readonly envelopeReq: EnvelopeRequest;
|
|
199
|
+
readonly handled: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface EnvelopeDispatchOutcome {
|
|
203
|
+
readonly result: CacheableResult | null;
|
|
204
|
+
readonly handled: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function dispatchEnvelopeRequest(options: {
|
|
208
|
+
readonly module: WasmServerModule;
|
|
209
|
+
readonly envelopeReq: EnvelopeRequest;
|
|
210
|
+
readonly method: string;
|
|
211
|
+
readonly url: string;
|
|
212
|
+
readonly cacheHost: string;
|
|
213
|
+
readonly hasAuth: boolean;
|
|
214
|
+
}): EnvelopeDispatchOutcome {
|
|
215
|
+
const cached = lookupCache(
|
|
216
|
+
options.cacheHost,
|
|
217
|
+
options.method,
|
|
218
|
+
options.url,
|
|
219
|
+
options.envelopeReq.body,
|
|
220
|
+
);
|
|
221
|
+
if (cached !== null) return { result: cached, handled: true };
|
|
222
|
+
|
|
223
|
+
const result = options.module.dispatch(options.envelopeReq);
|
|
224
|
+
if (result.unhandled) return { result: null, handled: false };
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
result: applyCacheRule(
|
|
228
|
+
options.cacheHost,
|
|
229
|
+
options.method,
|
|
230
|
+
options.url,
|
|
231
|
+
options.envelopeReq.body,
|
|
232
|
+
options.hasAuth,
|
|
233
|
+
result,
|
|
234
|
+
),
|
|
235
|
+
handled: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function dispatchWasmRequest(options: {
|
|
240
|
+
readonly module: WasmServerModule;
|
|
241
|
+
readonly request: Request;
|
|
242
|
+
readonly response: Response;
|
|
243
|
+
readonly root: string;
|
|
244
|
+
readonly cacheHost: string;
|
|
245
|
+
readonly serverHeader: string;
|
|
246
|
+
readonly errorPrefix: string;
|
|
247
|
+
}): Promise<WasmDispatchOutcome> {
|
|
248
|
+
const envelopeReq = await toEnvelopeRequest(options.request);
|
|
249
|
+
const hasAuth =
|
|
250
|
+
options.request.headers.cookie !== undefined ||
|
|
251
|
+
options.request.headers.authorization !== undefined;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const dispatch = dispatchEnvelopeRequest({
|
|
255
|
+
module: options.module,
|
|
256
|
+
envelopeReq,
|
|
257
|
+
method: options.request.method,
|
|
258
|
+
url: options.request.url,
|
|
259
|
+
cacheHost: options.cacheHost,
|
|
260
|
+
hasAuth,
|
|
261
|
+
});
|
|
262
|
+
if (dispatch.result !== null) {
|
|
263
|
+
sendWasmResponse(options.response, options.root, dispatch.result, options.serverHeader);
|
|
264
|
+
return { envelopeReq, handled: true };
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
process.stdout.write(
|
|
268
|
+
pc.red(
|
|
269
|
+
` ${options.errorPrefix} ${options.request.method} ${options.request.path} server error: ${String(e)}`,
|
|
270
|
+
) + '\n',
|
|
271
|
+
);
|
|
272
|
+
options.response.status(500).send('internal error\n');
|
|
273
|
+
return { envelopeReq, handled: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { envelopeReq, handled: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function assembleRouteSsr(
|
|
280
|
+
route: SsrRoute,
|
|
281
|
+
module: WasmServerModule | null,
|
|
282
|
+
envelopeReq: EnvelopeRequest,
|
|
283
|
+
): SsrResult | null {
|
|
284
|
+
if (route.entries.length === 0) return { status: 200, headers: [], html: route.tmpl };
|
|
285
|
+
if (module === null || !module.available) return null;
|
|
286
|
+
return assembleSsr(route, module.dispatchRender(envelopeReq));
|
|
287
|
+
}
|
package/src/devserver/index.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
export { startDevServer } from './server.js';
|
|
17
17
|
export type { DevServerOptions, RunningDevServer } from './server.js';
|
|
18
|
+
export { loadBuiltSsrTemplates, startBuiltServer } from './production.js';
|
|
19
|
+
export type { BuiltServerOptions, RunningBuiltServer } from './production.js';
|
|
18
20
|
|
|
19
21
|
export {
|
|
20
22
|
METHOD_CODES,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface ThreadedRequest {
|
|
2
|
+
readonly id: number;
|
|
3
|
+
readonly method: string;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
7
|
+
readonly body: string;
|
|
8
|
+
readonly clientIp: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ThreadedHttpResponse {
|
|
12
|
+
readonly kind: 'response';
|
|
13
|
+
readonly status: number;
|
|
14
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
15
|
+
readonly body: string;
|
|
16
|
+
readonly sendfile: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThreadedFallbackResponse {
|
|
20
|
+
readonly kind: 'fallback';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ThreadedReply = ThreadedHttpResponse | ThreadedFallbackResponse;
|
|
24
|
+
|
|
25
|
+
export type WorkerToPrimaryMessage =
|
|
26
|
+
| { readonly toil: 'ready'; readonly port: number; readonly workerId: number }
|
|
27
|
+
| { readonly toil: 'clientCount'; readonly count: number; readonly workerId: number }
|
|
28
|
+
| { readonly toil: 'request'; readonly request: ThreadedRequest };
|
|
29
|
+
|
|
30
|
+
export type PrimaryToWorkerMessage =
|
|
31
|
+
| {
|
|
32
|
+
readonly toil: 'start';
|
|
33
|
+
readonly workerId: number;
|
|
34
|
+
readonly options: unknown;
|
|
35
|
+
}
|
|
36
|
+
| { readonly toil: 'reply'; readonly id: number; readonly reply: ThreadedReply }
|
|
37
|
+
| { readonly toil: 'broadcast'; readonly message: string }
|
|
38
|
+
| { readonly toil: 'shutdown' };
|
|
39
|
+
|
|
40
|
+
export function encodeBody(body: Uint8Array): string {
|
|
41
|
+
return Buffer.from(body.buffer, body.byteOffset, body.length).toString('base64');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function decodeBody(body: string): Uint8Array {
|
|
45
|
+
return Buffer.from(body, 'base64');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isWorkerToPrimaryMessage(value: unknown): value is WorkerToPrimaryMessage {
|
|
49
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
50
|
+
const message = value as Record<string, unknown>;
|
|
51
|
+
return message.toil === 'ready' || message.toil === 'clientCount' || message.toil === 'request';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isPrimaryToWorkerMessage(value: unknown): value is PrimaryToWorkerMessage {
|
|
55
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
56
|
+
const message = value as Record<string, unknown>;
|
|
57
|
+
return (
|
|
58
|
+
message.toil === 'start' ||
|
|
59
|
+
message.toil === 'reply' ||
|
|
60
|
+
message.toil === 'broadcast' ||
|
|
61
|
+
message.toil === 'shutdown'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { BuiltServerOptions, RunningBuiltServer } from './production.js';
|
|
2
|
+
import { startBuiltServerWorker } from './production.js';
|
|
3
|
+
import {
|
|
4
|
+
isPrimaryToWorkerMessage,
|
|
5
|
+
type PrimaryToWorkerMessage,
|
|
6
|
+
type ThreadedReply,
|
|
7
|
+
type ThreadedRequest,
|
|
8
|
+
} from './production-ipc.js';
|
|
9
|
+
|
|
10
|
+
let running: RunningBuiltServer | null = null;
|
|
11
|
+
let workerId = 0;
|
|
12
|
+
const pending = new Map<number, (reply: ThreadedReply) => void>();
|
|
13
|
+
|
|
14
|
+
function send(message: object): void {
|
|
15
|
+
try {
|
|
16
|
+
process.send?.(message);
|
|
17
|
+
} catch {
|
|
18
|
+
// The parent is gone; normal process shutdown will follow.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requestPrimary(request: ThreadedRequest): Promise<ThreadedReply> {
|
|
23
|
+
const id = request.id;
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const timeout = setTimeout(() => {
|
|
26
|
+
pending.delete(id);
|
|
27
|
+
reject(new Error('primary request timed out'));
|
|
28
|
+
}, 120_000);
|
|
29
|
+
timeout.unref();
|
|
30
|
+
|
|
31
|
+
pending.set(id, (reply) => {
|
|
32
|
+
clearTimeout(timeout);
|
|
33
|
+
resolve(reply);
|
|
34
|
+
});
|
|
35
|
+
send({ toil: 'request', request });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function start(message: Extract<PrimaryToWorkerMessage, { toil: 'start' }>): Promise<void> {
|
|
40
|
+
if (running !== null) return;
|
|
41
|
+
workerId = message.workerId;
|
|
42
|
+
const options = message.options as BuiltServerOptions;
|
|
43
|
+
running = await startBuiltServerWorker(options, {
|
|
44
|
+
request: requestPrimary,
|
|
45
|
+
clientCount: (count) => send({ toil: 'clientCount', workerId, count }),
|
|
46
|
+
});
|
|
47
|
+
send({ toil: 'ready', workerId, port: running.port });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function shutdown(): Promise<void> {
|
|
51
|
+
const server = running;
|
|
52
|
+
running = null;
|
|
53
|
+
if (server !== null) await server.close();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.on('message', (value: unknown) => {
|
|
57
|
+
if (!isPrimaryToWorkerMessage(value)) return;
|
|
58
|
+
switch (value.toil) {
|
|
59
|
+
case 'start':
|
|
60
|
+
void start(value).catch((e: unknown) => {
|
|
61
|
+
process.stderr.write(`toiljs production worker failed: ${String(e)}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
case 'reply': {
|
|
66
|
+
const resolve = pending.get(value.id);
|
|
67
|
+
if (resolve === undefined) return;
|
|
68
|
+
pending.delete(value.id);
|
|
69
|
+
resolve(value.reply);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
case 'broadcast':
|
|
73
|
+
running?.broadcast(value.message);
|
|
74
|
+
return;
|
|
75
|
+
case 'shutdown':
|
|
76
|
+
void shutdown().finally(() => process.exit(0));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
process.once('disconnect', () => {
|
|
82
|
+
void shutdown().finally(() => process.exit(0));
|
|
83
|
+
});
|