@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6
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/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +39 -1
- package/dist/Text-BobFKi74.d.ts +452 -0
- package/dist/ansi.d.ts +122 -131
- package/dist/ansi.js +86 -6
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +3 -0
- package/dist/cell-_ZVhbfl0.js +44 -0
- package/dist/color-CkbalRqK.js +2 -0
- package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
- package/dist/color-policy-SVj1pYTA.js +560 -0
- package/dist/color-profile-DHhQHY55.js +36 -0
- package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
- package/dist/color.d.ts +21 -0
- package/dist/color.js +3 -0
- package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
- package/dist/detect-Bh4yGP6w.d.ts +186 -0
- package/dist/detect-BuTXtY6e.js +373 -0
- package/dist/env-YVw64yZS.js +9 -0
- package/dist/escapes-CB_6CWOE.d.ts +72 -0
- package/dist/geometry-BxXOzJgo.d.ts +11 -0
- package/dist/index-DZ88EXJv.d.ts +21 -0
- package/dist/index.d.ts +143 -498
- package/dist/index.js +1026 -2244
- package/dist/osc-CCH7xDoS.js +71 -0
- package/dist/osc-Cn0fw77g.d.ts +23 -0
- package/dist/paint-C19minOS.d.ts +81 -0
- package/dist/query-vaIeGOkH.d.ts +152 -0
- package/dist/router.d.ts +392 -0
- package/dist/router.js +709 -0
- package/dist/sample-Cqw1bjUL.js +445 -0
- package/dist/screen-BOSLQ8fF.d.ts +49 -0
- package/dist/screen-CiPytswf.js +342 -0
- package/dist/screen.d.ts +5 -0
- package/dist/screen.js +5 -0
- package/dist/semantic-text-style-DIMzC7xt.js +91 -0
- package/dist/serialize-BTkAZgw1.js +79 -0
- package/dist/session-aZr9O8h3.js +665 -0
- package/dist/sgr-BhwaWAJB.js +246 -0
- package/dist/store-CgrG9K4y.d.ts +72 -0
- package/dist/string-width-CijQwpIk.js +69 -0
- package/dist/strip-BvU4toXG.js +6 -0
- package/dist/terminal.d.ts +118 -0
- package/dist/terminal.js +2 -0
- package/dist/tokenize-AjqbvtiT.js +1242 -0
- package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
- package/dist/truncate-D31fhU6i.js +562 -0
- package/dist/use-focus-BzqAJi0n.js +1337 -0
- package/package.json +41 -9
- package/src/ansi/chalk.ts +5 -3
- package/src/ansi/escapes.ts +14 -0
- package/src/ansi/graphemes.ts +8 -0
- package/src/ansi/hyperlink.ts +44 -0
- package/src/ansi/index.ts +3 -1
- package/src/ansi/osc.ts +77 -0
- package/src/ansi/tokenize.ts +3 -4
- package/src/capabilities/color-policy.ts +34 -0
- package/src/capabilities/detect.ts +594 -0
- package/src/capabilities/index.ts +37 -0
- package/src/capabilities/query.ts +657 -0
- package/src/capabilities/store.ts +379 -0
- package/src/color/index.ts +3 -0
- package/src/color/paint.ts +169 -0
- package/src/color/palette.ts +48 -0
- package/src/color/sample.ts +323 -0
- package/src/color.ts +1 -0
- package/src/components/AnsiText.tsx +42 -0
- package/src/components/App.tsx +98 -10
- package/src/components/BackgroundContext.ts +2 -3
- package/src/components/Box.tsx +0 -8
- package/src/components/CursorContext.ts +1 -1
- package/src/components/Hyperlink.tsx +56 -0
- package/src/components/TerminalOscContext.ts +25 -0
- package/src/components/Text.tsx +22 -45
- package/src/components/Transform.tsx +1 -1
- package/src/dom.ts +11 -2
- package/src/global.d.ts +3 -0
- package/src/hooks/use-capabilities.ts +73 -0
- package/src/hooks/use-cursor.ts +2 -2
- package/src/hooks/use-terminal-osc.ts +59 -0
- package/src/index.ts +45 -1
- package/src/ink.tsx +213 -153
- package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
- package/src/reconciler.ts +24 -3
- package/src/render-background.ts +36 -15
- package/src/render-border.ts +94 -61
- package/src/render-frame.ts +83 -0
- package/src/render-to-string.ts +21 -6
- package/src/render.ts +15 -6
- package/src/router/components.tsx +343 -0
- package/src/router/context.ts +41 -0
- package/src/router/history.ts +194 -0
- package/src/router/hooks.tsx +391 -0
- package/src/router/index.ts +34 -0
- package/src/router/matcher.ts +571 -0
- package/src/screen/ansi.ts +184 -0
- package/src/screen/canvas.ts +160 -0
- package/src/screen/cell.ts +138 -0
- package/src/screen/color-profile.ts +47 -0
- package/src/screen/geometry.ts +9 -0
- package/src/screen/index.ts +6 -0
- package/src/screen/screen.ts +305 -0
- package/src/screen/serialize.ts +129 -0
- package/src/screen.ts +1 -0
- package/src/semantic-text-style.ts +118 -0
- package/src/squash-text-nodes.ts +2 -5
- package/src/structured-text.ts +325 -0
- package/src/styles.ts +19 -14
- package/src/terminal/index.ts +2 -0
- package/src/terminal/inline-presenter.ts +120 -0
- package/src/terminal/input.ts +86 -0
- package/src/terminal/render-scheduler.ts +37 -0
- package/src/terminal/screen-presenter.ts +188 -0
- package/src/terminal/session.ts +407 -0
- package/src/terminal.ts +1 -0
- package/src/testing/browser.ts +588 -0
- package/src/testing/emulators.ts +205 -0
- package/src/testing/explorer-app/index.html +12 -0
- package/src/testing/explorer-app/main.ts +381 -0
- package/src/testing/explorer-app/style.css +194 -0
- package/src/testing/explorer-app/tsconfig.json +15 -0
- package/src/testing/explorer-app/vite-env.d.ts +1 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/keys.ts +56 -0
- package/src/testing/live.ts +85 -0
- package/src/testing/matchers.ts +70 -0
- package/src/testing/public.ts +94 -0
- package/src/testing/terminal.ts +349 -0
- package/src/testing/vitest.ts +157 -0
- package/src/transform-adapter.ts +14 -0
- package/src/wrap-text.ts +4 -0
- package/dist/sgr-CMfEpjSk.d.ts +0 -91
- package/dist/truncate-Cr6xVFMa.js +0 -2330
- package/src/ansi/supports-color.ts +0 -207
- package/src/colorize.ts +0 -60
- package/src/log-update.ts +0 -370
- package/src/output.ts +0 -308
- package/src/renderer.ts +0 -73
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// Browser harnesses: terminal apps rendered by xterm.js in the browser,
|
|
2
|
+
// bridged over WebSocket to a fresh PTY per connection.
|
|
3
|
+
//
|
|
4
|
+
// - `serveTerminal(command)` hosts a single app.
|
|
5
|
+
// - `serveExplorer(entries)` hosts an index of apps (examples, test runs, …)
|
|
6
|
+
// with a terminal page per entry — the "open mode" for a repo.
|
|
7
|
+
//
|
|
8
|
+
// The WebSocket framing is implemented on top of node:http upgrades; the
|
|
9
|
+
// frontend is a Vite app (explorer-app/) mounted in middleware mode, with
|
|
10
|
+
// xterm.js and its full addon set as real npm imports.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { type Duplex } from "node:stream";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { type TestEngine } from "#/testing/vitest.ts";
|
|
19
|
+
|
|
20
|
+
export type ExplorerEntry = {
|
|
21
|
+
/**
|
|
22
|
+
Stable identifier used in URLs. Only registered ids can be launched —
|
|
23
|
+
nothing from the request ever reaches a shell.
|
|
24
|
+
*/
|
|
25
|
+
id: string;
|
|
26
|
+
|
|
27
|
+
title: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
Argv array, or a string run through `sh -c`.
|
|
31
|
+
*/
|
|
32
|
+
command: string | string[];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
Section heading in the index ("Examples", "Tests", …).
|
|
36
|
+
*/
|
|
37
|
+
group?: string;
|
|
38
|
+
|
|
39
|
+
cwd?: string;
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ServeTerminalOptions = {
|
|
44
|
+
/**
|
|
45
|
+
@default 0 (any free port)
|
|
46
|
+
*/
|
|
47
|
+
port?: number;
|
|
48
|
+
|
|
49
|
+
columns?: number;
|
|
50
|
+
rows?: number;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
env?: Record<string, string>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
Page title.
|
|
56
|
+
*/
|
|
57
|
+
title?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ServeExplorerOptions = Omit<ServeTerminalOptions, "title"> & {
|
|
61
|
+
title?: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
An embedded test engine (see `createVitestEngine`) — adds the test tree
|
|
65
|
+
with run buttons and live result streaming to the explorer UI.
|
|
66
|
+
*/
|
|
67
|
+
tests?: TestEngine;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type TerminalServer = {
|
|
71
|
+
url: string;
|
|
72
|
+
port: number;
|
|
73
|
+
close: () => Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
77
|
+
|
|
78
|
+
// Minimal RFC 6455 framing: unmasked text frames out, masked frames in.
|
|
79
|
+
const encodeTextFrame = (payload: string): Buffer => {
|
|
80
|
+
const data = Buffer.from(payload, "utf8");
|
|
81
|
+
if (data.length < 126) {
|
|
82
|
+
return Buffer.concat([Buffer.from([0x81, data.length]), data]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (data.length < 65_536) {
|
|
86
|
+
const header = Buffer.alloc(4);
|
|
87
|
+
header[0] = 0x81;
|
|
88
|
+
header[1] = 126;
|
|
89
|
+
header.writeUInt16BE(data.length, 2);
|
|
90
|
+
return Buffer.concat([header, data]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const header = Buffer.alloc(10);
|
|
94
|
+
header[0] = 0x81;
|
|
95
|
+
header[1] = 127;
|
|
96
|
+
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
97
|
+
return Buffer.concat([header, data]);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type DecodedFrame = { opcode: number; payload: Buffer; length: number };
|
|
101
|
+
|
|
102
|
+
const decodeFrame = (buffer: Buffer): DecodedFrame | undefined => {
|
|
103
|
+
if (buffer.length < 2) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const opcode = buffer[0]! & 0x0f;
|
|
108
|
+
const masked = (buffer[1]! & 0x80) !== 0;
|
|
109
|
+
let payloadLength = buffer[1]! & 0x7f;
|
|
110
|
+
let offset = 2;
|
|
111
|
+
|
|
112
|
+
if (payloadLength === 126) {
|
|
113
|
+
if (buffer.length < 4) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
118
|
+
offset = 4;
|
|
119
|
+
} else if (payloadLength === 127) {
|
|
120
|
+
if (buffer.length < 10) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
payloadLength = Number(buffer.readBigUInt64BE(2));
|
|
125
|
+
offset = 10;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const maskLength = masked ? 4 : 0;
|
|
129
|
+
if (buffer.length < offset + maskLength + payloadLength) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const mask = masked ? buffer.subarray(offset, offset + 4) : undefined;
|
|
134
|
+
const payload = Buffer.from(
|
|
135
|
+
buffer.subarray(offset + maskLength, offset + maskLength + payloadLength),
|
|
136
|
+
);
|
|
137
|
+
if (mask) {
|
|
138
|
+
for (let index = 0; index < payload.length; index++) {
|
|
139
|
+
payload[index]! ^= mask[index % 4]!;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { opcode, payload, length: offset + maskLength + payloadLength };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type CreateServerOptions = {
|
|
147
|
+
port: number;
|
|
148
|
+
columns: number;
|
|
149
|
+
rows: number;
|
|
150
|
+
mode: "explorer" | "terminal";
|
|
151
|
+
title: string;
|
|
152
|
+
tests: TestEngine | undefined;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const createTerminalServer = async (
|
|
156
|
+
entries: ExplorerEntry[],
|
|
157
|
+
{ port, columns, rows, mode, title, tests }: CreateServerOptions,
|
|
158
|
+
): Promise<TerminalServer> => {
|
|
159
|
+
const zigpty = await import("zigpty").catch((error) => {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'The "zigpty" package is required to launch terminal apps. Install it as a dev dependency: pnpm add -D zigpty',
|
|
162
|
+
{ cause: error },
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
167
|
+
const sockets = new Set<Duplex>();
|
|
168
|
+
|
|
169
|
+
// Live-session hub: harness-launched terminals (see live.ts) stream here;
|
|
170
|
+
// /live watchers render them in real time.
|
|
171
|
+
type LiveSession = {
|
|
172
|
+
id: string;
|
|
173
|
+
title: string;
|
|
174
|
+
columns: number;
|
|
175
|
+
rows: number;
|
|
176
|
+
data: string;
|
|
177
|
+
done: boolean;
|
|
178
|
+
exitCode: number | undefined;
|
|
179
|
+
};
|
|
180
|
+
const liveSessions = new Map<string, LiveSession>();
|
|
181
|
+
const liveWatchers = new Set<Duplex>();
|
|
182
|
+
const maxLiveSessions = 20;
|
|
183
|
+
const maxLiveBuffer = 2_000_000;
|
|
184
|
+
|
|
185
|
+
const broadcastLive = (message: object): void => {
|
|
186
|
+
const frame = encodeTextFrame(JSON.stringify(message));
|
|
187
|
+
for (const watcher of liveWatchers) {
|
|
188
|
+
if (!watcher.destroyed) {
|
|
189
|
+
watcher.write(frame);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const ingestLiveMessage = (message: {
|
|
195
|
+
type: string;
|
|
196
|
+
id?: string;
|
|
197
|
+
title?: string;
|
|
198
|
+
columns?: number;
|
|
199
|
+
rows?: number;
|
|
200
|
+
data?: string;
|
|
201
|
+
code?: number;
|
|
202
|
+
}): void => {
|
|
203
|
+
if (message.id === undefined) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (message.type === "start") {
|
|
208
|
+
liveSessions.set(message.id, {
|
|
209
|
+
id: message.id,
|
|
210
|
+
title: message.title ?? message.id,
|
|
211
|
+
columns: message.columns ?? 80,
|
|
212
|
+
rows: message.rows ?? 24,
|
|
213
|
+
data: "",
|
|
214
|
+
done: false,
|
|
215
|
+
exitCode: undefined,
|
|
216
|
+
});
|
|
217
|
+
// Cap retained sessions, dropping the oldest finished ones first.
|
|
218
|
+
for (const [id, session] of liveSessions) {
|
|
219
|
+
if (liveSessions.size <= maxLiveSessions) {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (session.done) {
|
|
224
|
+
liveSessions.delete(id);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
broadcastLive({ ...liveSessions.get(message.id), type: "start" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const session = liveSessions.get(message.id);
|
|
233
|
+
if (!session) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (message.type === "data" && message.data !== undefined) {
|
|
238
|
+
session.data = (session.data + message.data).slice(-maxLiveBuffer);
|
|
239
|
+
broadcastLive({ type: "data", id: session.id, data: message.data });
|
|
240
|
+
} else if (message.type === "title" && message.title !== undefined) {
|
|
241
|
+
session.title = message.title;
|
|
242
|
+
broadcastLive({ type: "title", id: session.id, title: message.title });
|
|
243
|
+
} else if (message.type === "end") {
|
|
244
|
+
session.done = true;
|
|
245
|
+
session.exitCode = message.code;
|
|
246
|
+
broadcastLive({ type: "end", id: session.id, code: message.code });
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const entryFor = (requestUrl: string | undefined): ExplorerEntry | undefined => {
|
|
251
|
+
const url = new URL(requestUrl ?? "/", "http://localhost");
|
|
252
|
+
const id = url.searchParams.get("app") ?? entries[0]?.id;
|
|
253
|
+
return id === null || id === undefined ? undefined : byId.get(id);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// The frontend is a Vite app (src/testing/explorer-app) mounted in
|
|
257
|
+
// middleware mode: our API and WebSocket endpoints are handled here, and
|
|
258
|
+
// everything else — the SPA, its modules, xterm.js and every addon as real
|
|
259
|
+
// npm imports, HMR — is Vite's.
|
|
260
|
+
const { createServer: createViteServer } = await import("vite").catch((error) => {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'The "vite" package is required for the browser harness. Install it as a dev dependency: pnpm add -D vite',
|
|
263
|
+
{ cause: error },
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const server: Server = createServer((request, response) => {
|
|
268
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
269
|
+
|
|
270
|
+
if (url.pathname === "/api/config") {
|
|
271
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
272
|
+
response.end(
|
|
273
|
+
JSON.stringify({
|
|
274
|
+
mode,
|
|
275
|
+
title,
|
|
276
|
+
columns,
|
|
277
|
+
rows,
|
|
278
|
+
tests: tests !== undefined,
|
|
279
|
+
entries: entries.map((entry) => ({
|
|
280
|
+
id: entry.id,
|
|
281
|
+
title: entry.title,
|
|
282
|
+
group: entry.group ?? "Apps",
|
|
283
|
+
})),
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (url.pathname === "/api/tests" && tests) {
|
|
290
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
291
|
+
response.end(JSON.stringify({ running: tests.running(), files: tests.tree() }));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (url.pathname === "/api/run" && request.method === "POST" && tests) {
|
|
296
|
+
const taskId = url.searchParams.get("task");
|
|
297
|
+
if (taskId) {
|
|
298
|
+
tests.runTask(taskId);
|
|
299
|
+
} else {
|
|
300
|
+
tests.runAll();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
response.writeHead(202);
|
|
304
|
+
response.end();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
vite.middlewares(request, response);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Next to this module in src; from a dist build, fall back to the shipped
|
|
312
|
+
// src copy.
|
|
313
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
314
|
+
const appRootCandidates = [
|
|
315
|
+
join(moduleDir, "explorer-app"),
|
|
316
|
+
join(moduleDir, "..", "src", "testing", "explorer-app"),
|
|
317
|
+
];
|
|
318
|
+
const appRoot = appRootCandidates.find((candidate) => existsSync(candidate));
|
|
319
|
+
if (!appRoot) {
|
|
320
|
+
throw new Error("The explorer frontend (src/testing/explorer-app) is missing.");
|
|
321
|
+
}
|
|
322
|
+
const vite = await createViteServer({
|
|
323
|
+
configFile: false,
|
|
324
|
+
root: appRoot,
|
|
325
|
+
appType: "spa",
|
|
326
|
+
logLevel: "warn",
|
|
327
|
+
cacheDir: join(process.cwd(), "node_modules", ".vite-sigil-explorer"),
|
|
328
|
+
server: { middlewareMode: true, hmr: { server } },
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
server.on("upgrade", (request: IncomingMessage, socket: Duplex) => {
|
|
332
|
+
const key = request.headers["sec-websocket-key"];
|
|
333
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
334
|
+
const entry = url.pathname === "/pty" ? entryFor(request.url) : undefined;
|
|
335
|
+
const isLive = url.pathname === "/live/ingest" || url.pathname === "/live/watch";
|
|
336
|
+
const ours = url.pathname === "/pty" || isLive;
|
|
337
|
+
if (!ours) {
|
|
338
|
+
// Not our endpoint — Vite's HMR upgrade listener handles its own.
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!entry && !isLive) {
|
|
343
|
+
socket.destroy();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!key) {
|
|
348
|
+
socket.destroy();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const accept = createHash("sha1")
|
|
353
|
+
.update(key + websocketGuid)
|
|
354
|
+
.digest("base64");
|
|
355
|
+
socket.write(
|
|
356
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
357
|
+
"Upgrade: websocket\r\n" +
|
|
358
|
+
"Connection: Upgrade\r\n" +
|
|
359
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
|
360
|
+
);
|
|
361
|
+
sockets.add(socket);
|
|
362
|
+
|
|
363
|
+
if (url.pathname === "/live/watch") {
|
|
364
|
+
liveWatchers.add(socket);
|
|
365
|
+
socket.write(
|
|
366
|
+
encodeTextFrame(
|
|
367
|
+
JSON.stringify({
|
|
368
|
+
type: "init",
|
|
369
|
+
sessions: [...liveSessions.values()],
|
|
370
|
+
tests: tests?.tree(),
|
|
371
|
+
running: tests?.running() ?? false,
|
|
372
|
+
}),
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
const dropWatcher = (): void => {
|
|
376
|
+
liveWatchers.delete(socket);
|
|
377
|
+
sockets.delete(socket);
|
|
378
|
+
};
|
|
379
|
+
socket.on("close", dropWatcher);
|
|
380
|
+
socket.on("error", dropWatcher);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (url.pathname === "/live/ingest") {
|
|
385
|
+
let pendingIngest = Buffer.alloc(0);
|
|
386
|
+
socket.on("data", (chunk: Buffer) => {
|
|
387
|
+
pendingIngest = Buffer.concat([pendingIngest, chunk]);
|
|
388
|
+
for (;;) {
|
|
389
|
+
const frame = decodeFrame(pendingIngest);
|
|
390
|
+
if (!frame) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
pendingIngest = pendingIngest.subarray(frame.length);
|
|
395
|
+
if (frame.opcode === 8) {
|
|
396
|
+
socket.destroy();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (frame.opcode === 1) {
|
|
401
|
+
try {
|
|
402
|
+
ingestLiveMessage(JSON.parse(frame.payload.toString("utf8")));
|
|
403
|
+
} catch {}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
const dropIngest = (): void => {
|
|
408
|
+
sockets.delete(socket);
|
|
409
|
+
};
|
|
410
|
+
socket.on("close", dropIngest);
|
|
411
|
+
socket.on("error", dropIngest);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!entry) {
|
|
416
|
+
socket.destroy();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const argv = Array.isArray(entry.command) ? entry.command : ["/bin/sh", "-c", entry.command];
|
|
421
|
+
// The app runs in an interactive truecolor terminal (xterm.js), whatever
|
|
422
|
+
// environment the explorer itself was launched from.
|
|
423
|
+
const childEnv: Record<string, string> = {
|
|
424
|
+
...(process.env as Record<string, string>),
|
|
425
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
426
|
+
COLORTERM: "truecolor",
|
|
427
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
428
|
+
NODE_NO_WARNINGS: "1",
|
|
429
|
+
...entry.env,
|
|
430
|
+
};
|
|
431
|
+
delete childEnv["CI"];
|
|
432
|
+
const child = zigpty.spawn(argv[0], argv.slice(1), {
|
|
433
|
+
name: "xterm-256color",
|
|
434
|
+
cols: columns,
|
|
435
|
+
rows,
|
|
436
|
+
cwd: entry.cwd ?? process.cwd(),
|
|
437
|
+
env: childEnv,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const send = (message: object): void => {
|
|
441
|
+
if (!socket.destroyed) {
|
|
442
|
+
socket.write(encodeTextFrame(JSON.stringify(message)));
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let exited = false;
|
|
447
|
+
child.onData((data: string | Buffer) => {
|
|
448
|
+
send({ type: "data", data: typeof data === "string" ? data : data.toString("utf8") });
|
|
449
|
+
});
|
|
450
|
+
child.onExit(({ exitCode }: { exitCode: number }) => {
|
|
451
|
+
exited = true;
|
|
452
|
+
send({ type: "exit", code: exitCode });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
let pending = Buffer.alloc(0);
|
|
456
|
+
socket.on("data", (chunk: Buffer) => {
|
|
457
|
+
pending = Buffer.concat([pending, chunk]);
|
|
458
|
+
for (;;) {
|
|
459
|
+
const frame = decodeFrame(pending);
|
|
460
|
+
if (!frame) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
pending = pending.subarray(frame.length);
|
|
465
|
+
if (frame.opcode === 8) {
|
|
466
|
+
socket.destroy();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (frame.opcode === 1) {
|
|
471
|
+
try {
|
|
472
|
+
const message = JSON.parse(frame.payload.toString("utf8")) as {
|
|
473
|
+
type: string;
|
|
474
|
+
data?: string;
|
|
475
|
+
columns?: number;
|
|
476
|
+
rows?: number;
|
|
477
|
+
};
|
|
478
|
+
if (message.type === "data" && message.data !== undefined && !exited) {
|
|
479
|
+
child.write(message.data);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (message.type === "resize" && message.columns && message.rows && !exited) {
|
|
483
|
+
child.resize(message.columns, message.rows);
|
|
484
|
+
}
|
|
485
|
+
} catch {}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const cleanup = (): void => {
|
|
491
|
+
sockets.delete(socket);
|
|
492
|
+
if (!exited) {
|
|
493
|
+
try {
|
|
494
|
+
child.kill();
|
|
495
|
+
} catch {}
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
socket.on("close", cleanup);
|
|
500
|
+
socket.on("error", cleanup);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const unsubscribeTests = tests?.onUpdate(() => {
|
|
504
|
+
broadcastLive({ type: "tests", files: tests.tree(), running: tests.running() });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
await new Promise<void>((resolve) => {
|
|
508
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
509
|
+
});
|
|
510
|
+
const address = server.address();
|
|
511
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
url: `http://127.0.0.1:${boundPort}/`,
|
|
515
|
+
port: boundPort,
|
|
516
|
+
close: async () => {
|
|
517
|
+
unsubscribeTests?.();
|
|
518
|
+
for (const socket of sockets) {
|
|
519
|
+
socket.destroy();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
await vite.close();
|
|
523
|
+
await new Promise<void>((resolve) => {
|
|
524
|
+
server.close(() => {
|
|
525
|
+
resolve();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
Serves a browser terminal running `command` — each page load (WebSocket
|
|
534
|
+
connection) gets its own PTY. Resolves once listening.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
const server = await serveTerminal(["node", "--import=tsx", "examples/router/index.ts"]);
|
|
538
|
+
console.log(`open ${server.url}`);
|
|
539
|
+
```
|
|
540
|
+
*/
|
|
541
|
+
export const serveTerminal = (
|
|
542
|
+
command: string | string[],
|
|
543
|
+
{
|
|
544
|
+
port = 0,
|
|
545
|
+
columns = 100,
|
|
546
|
+
rows = 30,
|
|
547
|
+
cwd = process.cwd(),
|
|
548
|
+
env = {},
|
|
549
|
+
title = "sigil terminal",
|
|
550
|
+
}: ServeTerminalOptions = {},
|
|
551
|
+
): Promise<TerminalServer> =>
|
|
552
|
+
createTerminalServer([{ id: "app", title, command, cwd, env }], {
|
|
553
|
+
port,
|
|
554
|
+
columns,
|
|
555
|
+
rows,
|
|
556
|
+
mode: "terminal",
|
|
557
|
+
title,
|
|
558
|
+
tests: undefined,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
Serves an explorer index over many terminal apps — the browser "open mode":
|
|
563
|
+
examples to poke at interactively, test runs to watch, each on its own
|
|
564
|
+
terminal page with a fresh PTY per load.
|
|
565
|
+
*/
|
|
566
|
+
export const serveExplorer = (
|
|
567
|
+
entries: ExplorerEntry[],
|
|
568
|
+
{
|
|
569
|
+
port = 0,
|
|
570
|
+
columns = 100,
|
|
571
|
+
rows = 30,
|
|
572
|
+
title = "sigil explorer",
|
|
573
|
+
tests,
|
|
574
|
+
}: ServeExplorerOptions = {},
|
|
575
|
+
): Promise<TerminalServer> => {
|
|
576
|
+
if (entries.length === 0) {
|
|
577
|
+
throw new Error("serveExplorer needs at least one entry.");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return createTerminalServer(entries, {
|
|
581
|
+
port,
|
|
582
|
+
columns,
|
|
583
|
+
rows,
|
|
584
|
+
mode: "explorer",
|
|
585
|
+
title,
|
|
586
|
+
tests,
|
|
587
|
+
});
|
|
588
|
+
};
|