pelican-engine 0.0.0-stage → 0.3.1
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/AGENTS.md +147 -0
- package/LICENSE +22 -0
- package/README.md +129 -2
- package/distribution.json +197 -0
- package/docs/AI-WORKFLOW.md +517 -0
- package/docs/ARCHITECTURE.md +268 -0
- package/docs/CAPABILITIES.md +264 -0
- package/docs/DECISIONS.md +107 -0
- package/docs/M3-USAGE.md +192 -0
- package/docs/PELICAN-MIGRATION.md +38 -0
- package/docs/RENDER-BACKENDS.md +50 -0
- package/docs/ROADMAP.md +239 -0
- package/docs/THIRD-PARTY-NOTICES.md +151 -0
- package/docs/api/REFERENCE.md +1435 -0
- package/docs/api/index.json +1139 -0
- package/docs/npm/DISTRIBUTION.md +130 -0
- package/docs/npm/GAME-AUTHOR.md +147 -0
- package/engine.manifest.json +15 -0
- package/lib/assets/audio.js +67 -0
- package/lib/assets/errors.js +36 -0
- package/lib/assets/glb.js +277 -0
- package/lib/assets/index.js +53 -0
- package/lib/assets/jpeg.js +199 -0
- package/lib/assets/model.js +558 -0
- package/lib/assets/optimize.js +95 -0
- package/lib/assets/texture.js +187 -0
- package/lib/compiler/distribution.js +17 -0
- package/lib/compiler/extract.js +425 -0
- package/lib/compiler/graph.js +366 -0
- package/lib/compiler/index.js +574 -0
- package/lib/compiler/normalize.js +1194 -0
- package/lib/compiler/source-map.js +25 -0
- package/lib/compiler/types.js +2 -0
- package/lib/compiler/version.js +93 -0
- package/lib/experiments/simulation/index.js +2 -0
- package/lib/experiments/simulation/math.js +41 -0
- package/lib/experiments/simulation/probe.js +215 -0
- package/lib/experiments/simulation/scenes.js +192 -0
- package/lib/experiments/simulation/solver.js +346 -0
- package/lib/experiments/simulation/types.js +1 -0
- package/lib/experiments/world/adapters.js +30 -0
- package/lib/experiments/world/geometry.js +180 -0
- package/lib/experiments/world/index.js +2 -0
- package/lib/experiments/world/probe.js +288 -0
- package/lib/experiments/world/types.js +1 -0
- package/lib/experiments/world/world.js +599 -0
- package/lib/navigation/index.js +157 -0
- package/lib/physics/index.js +909 -0
- package/lib/protocol/index.js +92 -0
- package/lib/renderer/acceleration.js +609 -0
- package/lib/renderer/browser.js +2015 -0
- package/lib/renderer/bsdf.js +260 -0
- package/lib/renderer/checkpoint.js +88 -0
- package/lib/renderer/compute-executor.js +1034 -0
- package/lib/renderer/compute-texture-assets.js +49 -0
- package/lib/renderer/compute-view-contract.js +1 -0
- package/lib/renderer/denoiser.js +361 -0
- package/lib/renderer/dielectric.js +360 -0
- package/lib/renderer/embedded-image-pixels.js +368 -0
- package/lib/renderer/emissive-lights.js +67 -0
- package/lib/renderer/environment-importance.js +55 -0
- package/lib/renderer/environment.js +346 -0
- package/lib/renderer/gameplay.js +464 -0
- package/lib/renderer/geometry.js +103 -0
- package/lib/renderer/graph-expressions.js +485 -0
- package/lib/renderer/graph-tsl.js +141 -0
- package/lib/renderer/graph.js +214 -0
- package/lib/renderer/managed-texture-sampling.js +29 -0
- package/lib/renderer/material-graphs.js +297 -0
- package/lib/renderer/medium.js +348 -0
- package/lib/renderer/models.js +1053 -0
- package/lib/renderer/motion-shader.js +40 -0
- package/lib/renderer/path-shader.js +196 -0
- package/lib/renderer/pathtrace.js +324 -0
- package/lib/renderer/postprocess-contract.js +1 -0
- package/lib/renderer/postprocess.js +494 -0
- package/lib/renderer/render-buffers.js +132 -0
- package/lib/renderer/render-textures.js +155 -0
- package/lib/renderer/startup.js +50 -0
- package/lib/renderer/surface-capture.js +199 -0
- package/lib/renderer/surface-gpu.js +270 -0
- package/lib/renderer/surface-motion.js +84 -0
- package/lib/renderer/surface-ray-shader.js +91 -0
- package/lib/renderer/surface-shader.js +88 -0
- package/lib/renderer/surface-tracer.js +1720 -0
- package/lib/renderer/surface.js +1425 -0
- package/lib/renderer/temporal-filter.js +512 -0
- package/lib/renderer/texture-pixels.js +207 -0
- package/lib/renderer/traversal-nodes.js +54 -0
- package/lib/renderer/ui.js +446 -0
- package/lib/renderer/vertex-bounds.js +127 -0
- package/lib/renderer/vertex-contract.js +1 -0
- package/lib/renderer/vertex-graphs.js +197 -0
- package/lib/renderer/volume-shader.js +50 -0
- package/lib/runtime/compute-contract.js +1 -0
- package/lib/runtime/compute-service.js +674 -0
- package/lib/runtime/contracts.js +1 -0
- package/lib/runtime/error.js +37 -0
- package/lib/runtime/events.js +189 -0
- package/lib/runtime/game-services.js +772 -0
- package/lib/runtime/host.js +839 -0
- package/lib/runtime/material-parameters.js +145 -0
- package/lib/runtime/plugins.js +1 -0
- package/lib/runtime/postprocess-contract.js +1 -0
- package/lib/runtime/postprocess.js +189 -0
- package/lib/runtime/prefabs.js +13 -0
- package/lib/runtime/random.js +88 -0
- package/lib/runtime/render-buffer-contract.js +1 -0
- package/lib/runtime/render-buffers.js +243 -0
- package/lib/runtime/render-capabilities.js +17 -0
- package/lib/runtime/render-settings.js +249 -0
- package/lib/runtime/render-texture-contract.js +1 -0
- package/lib/runtime/render-textures.js +271 -0
- package/lib/runtime/resources.js +648 -0
- package/lib/runtime/scheduler.js +765 -0
- package/lib/runtime/storage.js +169 -0
- package/lib/runtime/world.js +594 -0
- package/lib/schema/index.js +24 -0
- package/lib/sdk/graph-values.js +16 -0
- package/lib/sdk/index.d.ts +800 -0
- package/lib/sdk/index.js +82 -0
- package/lib/tools/api-index.js +113 -0
- package/lib/tools/browser-session.js +89 -0
- package/lib/tools/cli.js +513 -0
- package/lib/tools/compress-dist.js +59 -0
- package/lib/tools/control-client.js +153 -0
- package/lib/tools/control-server.js +633 -0
- package/lib/tools/diagnostic-code.js +3 -0
- package/lib/tools/doctor.js +496 -0
- package/lib/tools/npm/browser-cli.js +6 -0
- package/lib/tools/npm/entry.js +40 -0
- package/lib/tools/npm/layout.js +195 -0
- package/lib/tools/setup.js +153 -0
- package/licenses/@dimforge/rapier2d-compat/LICENSE +201 -0
- package/licenses/@dimforge/rapier3d-compat/LICENSE +201 -0
- package/licenses/@gltf-transform/core/LICENSE.md +21 -0
- package/licenses/@gltf-transform/extensions/LICENSE.md +21 -0
- package/licenses/@gltf-transform/functions/LICENSE.md +21 -0
- package/licenses/@img/colour/LICENSE.md +82 -0
- package/licenses/@img/sharp-win32-x64/LICENSE +191 -0
- package/licenses/@oxc-project/types/LICENSE +22 -0
- package/licenses/@rolldown/pluginutils/LICENSE +21 -0
- package/licenses/@tweenjs/tween.js/LICENSE +23 -0
- package/licenses/@types/ndarray/LICENSE +21 -0
- package/licenses/@types/node/LICENSE +21 -0
- package/licenses/@types/stats.js/LICENSE +21 -0
- package/licenses/@types/three/@dimforge/rapier3d-compat/LICENSE +201 -0
- package/licenses/@types/three/LICENSE +21 -0
- package/licenses/@types/webxr/LICENSE +21 -0
- package/licenses/@webgpu/types/LICENSE +26 -0
- package/licenses/THIRD-PARTY-NOTICES.md +139 -0
- package/licenses/cwise-compiler/LICENSE +22 -0
- package/licenses/detect-libc/LICENSE +201 -0
- package/licenses/esbuild/LICENSE.md +21 -0
- package/licenses/fast-png/LICENSE +21 -0
- package/licenses/fdir/LICENSE +7 -0
- package/licenses/fflate/LICENSE +21 -0
- package/licenses/iobuffer/LICENSE +22 -0
- package/licenses/iota-array/LICENSE +22 -0
- package/licenses/is-buffer/LICENSE +21 -0
- package/licenses/ktx-parse/LICENSE +21 -0
- package/licenses/lightningcss/LICENSE +373 -0
- package/licenses/lightningcss-win32-x64-msvc/LICENSE +373 -0
- package/licenses/meshoptimizer/LICENSE.md +21 -0
- package/licenses/nanoid/LICENSE +20 -0
- package/licenses/ndarray/LICENSE +22 -0
- package/licenses/ndarray-lanczos/LICENSE +21 -0
- package/licenses/ndarray-ops/LICENSE +22 -0
- package/licenses/ndarray-pixels/LICENSE +21 -0
- package/licenses/ngraph.path/LICENSE +21 -0
- package/licenses/picocolors/LICENSE +15 -0
- package/licenses/picomatch/LICENSE +21 -0
- package/licenses/playwright/LICENSE +202 -0
- package/licenses/playwright/NOTICE +5 -0
- package/licenses/playwright-core/LICENSE +202 -0
- package/licenses/playwright-core/NOTICE +5 -0
- package/licenses/postcss/LICENSE +20 -0
- package/licenses/prettier/LICENSE +7 -0
- package/licenses/property-graph/LICENSE +21 -0
- package/licenses/rolldown/LICENSE +25 -0
- package/licenses/semver/LICENSE +15 -0
- package/licenses/sharp/LICENSE +191 -0
- package/licenses/source-map-js/LICENSE +28 -0
- package/licenses/three/LICENSE +21 -0
- package/licenses/tinyglobby/LICENSE +21 -0
- package/licenses/tsx/LICENSE +21 -0
- package/licenses/typescript/LICENSE.txt +55 -0
- package/licenses/undici-types/LICENSE +21 -0
- package/licenses/uniq/LICENSE +22 -0
- package/licenses/vite/LICENSE.md +2084 -0
- package/package.json +49 -4
- package/web/index.html +34 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AutomationError, automationLaunchOptions, automationURL, waitForEngine, } from "./browser-session.js";
|
|
3
|
+
import { parseCommand, PROTOCOL_VERSION, result, } from "../protocol/index.js";
|
|
4
|
+
const BODY_LIMIT = 1024 * 1024;
|
|
5
|
+
// Base64 plus the envelope must fit the unchanged client's 8 MiB response cap.
|
|
6
|
+
const CAPTURE_LIMIT = 6 * BODY_LIMIT - 3 * 1024;
|
|
7
|
+
const HISTORY_LIMIT = 256;
|
|
8
|
+
const QUEUE_LIMIT = 64;
|
|
9
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
10
|
+
const MAX_TIMEOUT = 120_000;
|
|
11
|
+
const ID_PATTERN = /^[a-zA-Z0-9_-]{1,96}$/;
|
|
12
|
+
const attached = new WeakSet();
|
|
13
|
+
class BridgeError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
httpStatus;
|
|
16
|
+
constructor(code, httpStatus = 503) {
|
|
17
|
+
super(code);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.httpStatus = httpStatus;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function timeoutValue(value = DEFAULT_TIMEOUT) {
|
|
23
|
+
if (!Number.isInteger(value) || value < 10 || value > MAX_TIMEOUT)
|
|
24
|
+
throw new Error("TC_CONTROL_TIMEOUT_RANGE: timeoutMs must be an integer in [10,120000].");
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function diagnostic(code, message, remediation = "Inspect task status; explicitly reset the control session before retrying if stateReset is true.") {
|
|
28
|
+
return { code, severity: "error", message, remediation };
|
|
29
|
+
}
|
|
30
|
+
function failed(id, code, message, data = null, status = "failed") {
|
|
31
|
+
return result(id, status, data, [diagnostic(code, message)]);
|
|
32
|
+
}
|
|
33
|
+
function loopback(address) {
|
|
34
|
+
const value = address?.toLowerCase().replace(/^::ffff:/, "");
|
|
35
|
+
return (value === "::1" ||
|
|
36
|
+
value === "[::1]" ||
|
|
37
|
+
value === "localhost" ||
|
|
38
|
+
/^127(?:\.\d{1,3}){3}$/.test(value ?? ""));
|
|
39
|
+
}
|
|
40
|
+
function record(value) {
|
|
41
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
function requestId(req) {
|
|
44
|
+
const id = req.headers["x-pelican-task-id"];
|
|
45
|
+
return typeof id === "string" && ID_PATTERN.test(id)
|
|
46
|
+
? id
|
|
47
|
+
: `control-${randomUUID()}`;
|
|
48
|
+
}
|
|
49
|
+
function writeJSON(res, httpStatus, value) {
|
|
50
|
+
if (res.destroyed || res.writableEnded)
|
|
51
|
+
return;
|
|
52
|
+
res.writeHead(httpStatus, {
|
|
53
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
54
|
+
"Cache-Control": "no-store",
|
|
55
|
+
"X-Content-Type-Options": "nosniff",
|
|
56
|
+
...([408, 413].includes(httpStatus) ? { Connection: "close" } : {}),
|
|
57
|
+
});
|
|
58
|
+
res.end(JSON.stringify(value));
|
|
59
|
+
}
|
|
60
|
+
/** Bounded even for chunked requests and peers that never finish sending. */
|
|
61
|
+
function readJSON(req, timeoutMs) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
let size = 0;
|
|
64
|
+
let settled = false;
|
|
65
|
+
const length = req.headers["content-length"];
|
|
66
|
+
let oversized = length !== undefined && Number(length) > BODY_LIMIT;
|
|
67
|
+
const chunks = [];
|
|
68
|
+
const finish = (error, value) => {
|
|
69
|
+
if (settled)
|
|
70
|
+
return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
req.off("data", onData);
|
|
74
|
+
req.off("end", onEnd);
|
|
75
|
+
req.off("error", onError);
|
|
76
|
+
req.off("aborted", onError);
|
|
77
|
+
if (error) {
|
|
78
|
+
req.resume();
|
|
79
|
+
reject(error);
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
resolve(value);
|
|
83
|
+
};
|
|
84
|
+
const onData = (chunk) => {
|
|
85
|
+
if (oversized)
|
|
86
|
+
return;
|
|
87
|
+
size += chunk.length;
|
|
88
|
+
if (size > BODY_LIMIT) {
|
|
89
|
+
oversized = true;
|
|
90
|
+
chunks.length = 0;
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
};
|
|
95
|
+
const onEnd = () => {
|
|
96
|
+
if (oversized) {
|
|
97
|
+
finish(new BridgeError("TC_CONTROL_BODY_TOO_LARGE", 413));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
finish(undefined, JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
finish(new BridgeError("TC_CONTROL_JSON_INVALID", 400));
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const onError = () => finish(new BridgeError("TC_CONTROL_REQUEST_ABORTED", 400));
|
|
108
|
+
const timer = setTimeout(() => finish(new BridgeError(oversized ? "TC_CONTROL_BODY_TOO_LARGE" : "TC_CONTROL_BODY_TIMEOUT", oversized ? 413 : 408)), timeoutMs);
|
|
109
|
+
req.on("data", onData);
|
|
110
|
+
req.once("end", onEnd);
|
|
111
|
+
req.once("error", onError);
|
|
112
|
+
req.once("aborted", onError);
|
|
113
|
+
// Drain rejected uploads without retaining bytes, then close after the response.
|
|
114
|
+
// Closing with unread inbound data can reset a valid client's 413 response.
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Internal screenshot adapter, exported only to exercise the real path with CPU fixtures. */
|
|
118
|
+
export async function captureControlPage(page, timeoutMs) {
|
|
119
|
+
const viewport = page.viewportSize();
|
|
120
|
+
if (!viewport || viewport.width <= 0 || viewport.height <= 0)
|
|
121
|
+
return failed("capture", "TC_CONTROL_CAPTURE_VIEWPORT", "The owned page has no valid configured viewport; no image was returned.");
|
|
122
|
+
const png = await page.screenshot({
|
|
123
|
+
type: "png",
|
|
124
|
+
timeout: timeoutValue(timeoutMs),
|
|
125
|
+
});
|
|
126
|
+
if (png.byteLength > CAPTURE_LIMIT)
|
|
127
|
+
return failed("capture", "TC_CONTROL_CAPTURE_TOO_LARGE", "The PNG exceeds the bounded capture transport size; no image was returned.", { format: "png", bytes: png.byteLength, limitBytes: CAPTURE_LIMIT });
|
|
128
|
+
return result("capture", "succeeded", {
|
|
129
|
+
format: "png",
|
|
130
|
+
base64: png.toString("base64"),
|
|
131
|
+
width: viewport.width,
|
|
132
|
+
height: viewport.height,
|
|
133
|
+
bytes: png.byteLength,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** No init scripts or nested evaluated functions: safe under TSX's __name transform. */
|
|
137
|
+
const physicalPageFactory = async ({ url, timeoutMs, signal, }) => {
|
|
138
|
+
const { chromium } = await import("playwright");
|
|
139
|
+
let browser;
|
|
140
|
+
const abort = () => {
|
|
141
|
+
void browser?.close().catch(() => undefined);
|
|
142
|
+
};
|
|
143
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
144
|
+
try {
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
throw new BridgeError("TC_CONTROL_SESSION_STALE");
|
|
147
|
+
browser = await chromium.launch(automationLaunchOptions(timeoutMs));
|
|
148
|
+
if (signal.aborted)
|
|
149
|
+
throw new BridgeError("TC_CONTROL_SESSION_STALE");
|
|
150
|
+
const context = await browser.newContext({
|
|
151
|
+
viewport: { width: 960, height: 540 },
|
|
152
|
+
serviceWorkers: "block",
|
|
153
|
+
});
|
|
154
|
+
const origin = new URL(url).origin;
|
|
155
|
+
await context.route("**/*", (route) => {
|
|
156
|
+
if (new URL(route.request().url()).origin === origin)
|
|
157
|
+
return route.continue();
|
|
158
|
+
return route.abort("blockedbyclient");
|
|
159
|
+
});
|
|
160
|
+
await context.routeWebSocket("**/*", (socket) => {
|
|
161
|
+
if (new URL(socket.url()).origin.replace(/^ws/, "http") === origin)
|
|
162
|
+
socket.connectToServer();
|
|
163
|
+
else
|
|
164
|
+
socket.close();
|
|
165
|
+
});
|
|
166
|
+
const page = await context.newPage();
|
|
167
|
+
await page.goto(automationURL(url), {
|
|
168
|
+
waitUntil: "domcontentloaded",
|
|
169
|
+
timeout: timeoutMs,
|
|
170
|
+
});
|
|
171
|
+
await waitForEngine(page, timeoutMs);
|
|
172
|
+
if (signal.aborted)
|
|
173
|
+
throw new BridgeError("TC_CONTROL_RUNTIME_UNAVAILABLE");
|
|
174
|
+
const ownedBrowser = browser;
|
|
175
|
+
return {
|
|
176
|
+
isClosed: () => page.isClosed() || !ownedBrowser.isConnected(),
|
|
177
|
+
dispatch: (command) => command.type === "capture"
|
|
178
|
+
? captureControlPage(page, timeoutMs)
|
|
179
|
+
: page.evaluate(async (value) => {
|
|
180
|
+
const api = window.__pelican;
|
|
181
|
+
if (!api || typeof api.command !== "function")
|
|
182
|
+
throw new Error("TC_CONTROL_RUNTIME_UNAVAILABLE");
|
|
183
|
+
return api.command(value);
|
|
184
|
+
}, command),
|
|
185
|
+
close: async () => {
|
|
186
|
+
signal.removeEventListener("abort", abort);
|
|
187
|
+
await ownedBrowser.close();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
signal.removeEventListener("abort", abort);
|
|
193
|
+
await browser?.close().catch(() => undefined);
|
|
194
|
+
throw new BridgeError(error instanceof AutomationError
|
|
195
|
+
? error.code
|
|
196
|
+
: "TC_CONTROL_BROWSER_UNAVAILABLE");
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/** Attach before server.listen(), or after listen: the actual bound port is resolved lazily.
|
|
200
|
+
* POST /__pelican/control -> ControlCommand -> Envelope (same browser until reset).
|
|
201
|
+
* GET /__pelican/status -> Envelope<ControlStatus> (bounded metadata, no snapshots).
|
|
202
|
+
* POST /__pelican/cancel {taskId}; POST /__pelican/session {action:"reset"}.
|
|
203
|
+
* An aborted HTTP command also cancels its task. Running cancellation discards state.
|
|
204
|
+
*/
|
|
205
|
+
export function attachControl(server, options = {}) {
|
|
206
|
+
return attach(server, options, physicalPageFactory);
|
|
207
|
+
}
|
|
208
|
+
/** Test-only dependency injection; not an alternate engine API or GPU verification path. */
|
|
209
|
+
export function attachControlForTests(server, pageFactory, options = {}) {
|
|
210
|
+
return attach(server, options, pageFactory);
|
|
211
|
+
}
|
|
212
|
+
function attach(server, options, pageFactory) {
|
|
213
|
+
const timeoutMs = timeoutValue(options.timeoutMs);
|
|
214
|
+
if (server.config.command !== "serve" || server.config.isProduction)
|
|
215
|
+
throw new Error("TC_CONTROL_DEV_ONLY: Control is available only on a development server.");
|
|
216
|
+
if (!server.httpServer)
|
|
217
|
+
throw new Error("TC_CONTROL_HTTP_REQUIRED: A local HTTP server is required.");
|
|
218
|
+
if (attached.has(server))
|
|
219
|
+
throw new Error("TC_CONTROL_ALREADY_ATTACHED: Only one control bridge is allowed per server.");
|
|
220
|
+
attached.add(server);
|
|
221
|
+
const tasks = new Map();
|
|
222
|
+
// Terminal history contains metadata only, never command bodies or resolved snapshots.
|
|
223
|
+
const history = new Map();
|
|
224
|
+
const queue = [];
|
|
225
|
+
const cleanup = new Set();
|
|
226
|
+
let generation = 0;
|
|
227
|
+
let session;
|
|
228
|
+
let needsReset = false;
|
|
229
|
+
let resetting;
|
|
230
|
+
let closed = false;
|
|
231
|
+
let draining = false;
|
|
232
|
+
let active;
|
|
233
|
+
let closePromise;
|
|
234
|
+
const state = () => ({
|
|
235
|
+
stateReset: true,
|
|
236
|
+
sessionNeedsReset: needsReset,
|
|
237
|
+
generation,
|
|
238
|
+
});
|
|
239
|
+
function settle(task, value) {
|
|
240
|
+
if (task.summary.status !== "queued" && task.summary.status !== "running")
|
|
241
|
+
return;
|
|
242
|
+
clearTimeout(task.timer);
|
|
243
|
+
task.summary.status = value.status;
|
|
244
|
+
task.summary.finishedAt = Date.now();
|
|
245
|
+
tasks.delete(task.summary.taskId);
|
|
246
|
+
const queuedIndex = queue.indexOf(task);
|
|
247
|
+
if (queuedIndex >= 0)
|
|
248
|
+
queue.splice(queuedIndex, 1);
|
|
249
|
+
task.finish(value);
|
|
250
|
+
}
|
|
251
|
+
function prune() {
|
|
252
|
+
for (const [id, summary] of history) {
|
|
253
|
+
if (history.size < HISTORY_LIMIT)
|
|
254
|
+
break;
|
|
255
|
+
if (summary.status !== "queued" && summary.status !== "running")
|
|
256
|
+
history.delete(id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function retire(old) {
|
|
260
|
+
if (old.cleanup)
|
|
261
|
+
return;
|
|
262
|
+
old.abort.abort();
|
|
263
|
+
const pending = old.ready.then((page) => page.close(), () => undefined);
|
|
264
|
+
old.cleanup = pending;
|
|
265
|
+
cleanup.add(pending);
|
|
266
|
+
// Always observe both fulfillment and rejection, including late factory/evaluate failures.
|
|
267
|
+
void pending.then(() => cleanup.delete(pending), () => {
|
|
268
|
+
/* Keep failed cleanup: reset must not silently continue. */
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function discard() {
|
|
272
|
+
generation++;
|
|
273
|
+
needsReset = true;
|
|
274
|
+
const old = session;
|
|
275
|
+
session = undefined;
|
|
276
|
+
if (old)
|
|
277
|
+
retire(old);
|
|
278
|
+
for (const task of [...queue])
|
|
279
|
+
if (task.summary.status === "queued")
|
|
280
|
+
settle(task, failed(task.summary.taskId, "TC_CONTROL_SESSION_STALE", "Queued command was not executed because the session was discarded.", state(), "cancelled"));
|
|
281
|
+
}
|
|
282
|
+
function terminate(task, status, code) {
|
|
283
|
+
if (task.summary.status !== "queued" && task.summary.status !== "running")
|
|
284
|
+
return;
|
|
285
|
+
const running = task.summary.status === "running";
|
|
286
|
+
if (running)
|
|
287
|
+
discard();
|
|
288
|
+
settle(task, failed(task.summary.taskId, code, running
|
|
289
|
+
? "The owned browser session was discarded; explicitly reset before sending another command."
|
|
290
|
+
: "The queued command was not executed.", running ? state() : { stateReset: false, executed: false, generation }, status));
|
|
291
|
+
}
|
|
292
|
+
function serverURL() {
|
|
293
|
+
const address = server.httpServer?.address();
|
|
294
|
+
if (!address || typeof address === "string" || !address.port)
|
|
295
|
+
throw new BridgeError("TC_CONTROL_SERVER_NOT_LISTENING");
|
|
296
|
+
// Never trust Host, resolvedUrls or the configured (possibly busy/zero) port.
|
|
297
|
+
const base = server.config.base.startsWith("/") && !server.config.base.startsWith("//")
|
|
298
|
+
? server.config.base
|
|
299
|
+
: "/";
|
|
300
|
+
return `http://127.0.0.1:${address.port}${base}`;
|
|
301
|
+
}
|
|
302
|
+
async function getPage(task) {
|
|
303
|
+
if (!session) {
|
|
304
|
+
const url = serverURL();
|
|
305
|
+
const abort = new AbortController();
|
|
306
|
+
const current = {
|
|
307
|
+
generation,
|
|
308
|
+
abort,
|
|
309
|
+
ready: Promise.resolve().then(() => pageFactory({ url, timeoutMs, signal: abort.signal })),
|
|
310
|
+
};
|
|
311
|
+
session = current;
|
|
312
|
+
void current.ready.then((page) => {
|
|
313
|
+
current.page = page;
|
|
314
|
+
}, () => undefined);
|
|
315
|
+
}
|
|
316
|
+
const owner = session;
|
|
317
|
+
const page = await owner.ready;
|
|
318
|
+
if (closed ||
|
|
319
|
+
owner !== session ||
|
|
320
|
+
owner.generation !== task.summary.generation ||
|
|
321
|
+
task.summary.status !== "running")
|
|
322
|
+
throw new BridgeError("TC_CONTROL_SESSION_STALE");
|
|
323
|
+
if (page.isClosed())
|
|
324
|
+
throw new BridgeError("TC_CONTROL_BROWSER_CLOSED");
|
|
325
|
+
return page;
|
|
326
|
+
}
|
|
327
|
+
function normalized(task, payload) {
|
|
328
|
+
if (!record(payload) ||
|
|
329
|
+
payload.protocolVersion !== PROTOCOL_VERSION ||
|
|
330
|
+
!["succeeded", "failed"].includes(String(payload.status)) ||
|
|
331
|
+
!Array.isArray(payload.diagnostics) ||
|
|
332
|
+
!Array.isArray(payload.artifacts) ||
|
|
333
|
+
!("data" in payload))
|
|
334
|
+
throw new BridgeError("TC_CONTROL_RESPONSE_INVALID");
|
|
335
|
+
// Never reflect raw browser errors, stacks, paths or untrusted diagnostic messages.
|
|
336
|
+
const diagnostics = payload.diagnostics
|
|
337
|
+
.slice(0, 32)
|
|
338
|
+
.map((value) => {
|
|
339
|
+
const code = record(value) &&
|
|
340
|
+
typeof value.code === "string" &&
|
|
341
|
+
/^TC[A-Z0-9_]{1,90}$/.test(value.code)
|
|
342
|
+
? value.code
|
|
343
|
+
: "TC_CONTROL_RUNTIME_FAILURE";
|
|
344
|
+
return diagnostic(code, "Runtime command reported a diagnostic. Inspect the referenced command and project source.", "Correct the command or project source and retry; a runtime failure envelope alone does not reset the session.");
|
|
345
|
+
});
|
|
346
|
+
const value = result(task.summary.taskId, payload.status, payload.data === undefined ? null : payload.data, diagnostics);
|
|
347
|
+
const json = JSON.stringify(value);
|
|
348
|
+
if (Buffer.byteLength(json) > 8 * BODY_LIMIT)
|
|
349
|
+
throw new BridgeError("TC_CONTROL_RESPONSE_TOO_LARGE");
|
|
350
|
+
return JSON.parse(json);
|
|
351
|
+
}
|
|
352
|
+
async function drain() {
|
|
353
|
+
if (draining)
|
|
354
|
+
return;
|
|
355
|
+
draining = true;
|
|
356
|
+
try {
|
|
357
|
+
while (!closed) {
|
|
358
|
+
const task = queue.shift();
|
|
359
|
+
if (!task)
|
|
360
|
+
break;
|
|
361
|
+
if (task.summary.status !== "queued")
|
|
362
|
+
continue;
|
|
363
|
+
if (needsReset || resetting) {
|
|
364
|
+
settle(task, failed(task.summary.taskId, "TC_CONTROL_SESSION_RESET_REQUIRED", "Explicitly reset the session before retrying.", state(), "cancelled"));
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
active = task;
|
|
368
|
+
task.summary.status = "running";
|
|
369
|
+
task.summary.startedAt = Date.now();
|
|
370
|
+
try {
|
|
371
|
+
const execution = getPage(task).then((page) => page.dispatch(task.command));
|
|
372
|
+
const payload = await Promise.race([execution, task.done]);
|
|
373
|
+
if (task.summary.status !== "running")
|
|
374
|
+
continue;
|
|
375
|
+
if (task.summary.generation !== generation)
|
|
376
|
+
throw new BridgeError("TC_CONTROL_SESSION_STALE");
|
|
377
|
+
settle(task, normalized(task, payload));
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (task.summary.status !== "running")
|
|
381
|
+
continue;
|
|
382
|
+
const code = error instanceof BridgeError
|
|
383
|
+
? error.code
|
|
384
|
+
: "TC_CONTROL_TRANSPORT_FAILURE";
|
|
385
|
+
discard();
|
|
386
|
+
settle(task, failed(task.summary.taskId, code, "Browser transport failed. The owned session was discarded; explicitly reset before retrying.", state()));
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
if (active === task)
|
|
390
|
+
active = undefined;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
draining = false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function waitForCleanup() {
|
|
399
|
+
if (!cleanup.size)
|
|
400
|
+
return true;
|
|
401
|
+
let timer;
|
|
402
|
+
try {
|
|
403
|
+
return await Promise.race([
|
|
404
|
+
Promise.all([...cleanup]).then(() => true, () => false),
|
|
405
|
+
new Promise((resolve) => {
|
|
406
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
407
|
+
}),
|
|
408
|
+
]);
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function resetSession() {
|
|
415
|
+
if (resetting)
|
|
416
|
+
return resetting;
|
|
417
|
+
if (active?.summary.status === "running")
|
|
418
|
+
terminate(active, "cancelled", "TC_CONTROL_SESSION_RESET");
|
|
419
|
+
else
|
|
420
|
+
discard();
|
|
421
|
+
const token = generation;
|
|
422
|
+
resetting = waitForCleanup()
|
|
423
|
+
.then((ok) => {
|
|
424
|
+
if (ok && !closed && generation === token)
|
|
425
|
+
needsReset = false;
|
|
426
|
+
return ok && !closed && generation === token;
|
|
427
|
+
})
|
|
428
|
+
.finally(() => {
|
|
429
|
+
resetting = undefined;
|
|
430
|
+
});
|
|
431
|
+
return resetting;
|
|
432
|
+
}
|
|
433
|
+
function assertLocal(req) {
|
|
434
|
+
if (!loopback(req.socket.remoteAddress) ||
|
|
435
|
+
!loopback(req.socket.localAddress))
|
|
436
|
+
throw new BridgeError("TC_CONTROL_LOOPBACK_ONLY", 403);
|
|
437
|
+
const authority = req.headers.host;
|
|
438
|
+
if (!authority || /[\s/@?#\\]/.test(authority))
|
|
439
|
+
throw new BridgeError("TC_CONTROL_HOST_REJECTED", 403);
|
|
440
|
+
let host;
|
|
441
|
+
try {
|
|
442
|
+
host = new URL(`http://${authority}`);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
throw new BridgeError("TC_CONTROL_HOST_REJECTED", 403);
|
|
446
|
+
}
|
|
447
|
+
if (!loopback(host.hostname) || host.port !== new URL(serverURL()).port)
|
|
448
|
+
throw new BridgeError("TC_CONTROL_HOST_REJECTED", 403);
|
|
449
|
+
const origin = req.headers.origin;
|
|
450
|
+
if (origin !== undefined && origin !== host.origin)
|
|
451
|
+
throw new BridgeError("TC_CONTROL_ORIGIN_REJECTED", 403);
|
|
452
|
+
const site = req.headers["sec-fetch-site"];
|
|
453
|
+
if (site && site !== "same-origin" && site !== "none")
|
|
454
|
+
throw new BridgeError("TC_CONTROL_ORIGIN_REJECTED", 403);
|
|
455
|
+
if (req.headers["access-control-request-method"])
|
|
456
|
+
throw new BridgeError("TC_CONTROL_ORIGIN_REJECTED", 403);
|
|
457
|
+
}
|
|
458
|
+
async function handle(req, res, route) {
|
|
459
|
+
const id = requestId(req);
|
|
460
|
+
try {
|
|
461
|
+
assertLocal(req);
|
|
462
|
+
if (closed)
|
|
463
|
+
throw new BridgeError("TC_CONTROL_CLOSED", 503);
|
|
464
|
+
if (route === "/__pelican/status") {
|
|
465
|
+
if (req.method !== "GET")
|
|
466
|
+
throw new BridgeError("TC_CONTROL_METHOD", 405);
|
|
467
|
+
const data = {
|
|
468
|
+
generation,
|
|
469
|
+
sessionNeedsReset: needsReset,
|
|
470
|
+
resetting: !!resetting,
|
|
471
|
+
hasPage: !!session?.page && !session.page.isClosed(),
|
|
472
|
+
closed,
|
|
473
|
+
tasks: [...history.values()].map((summary) => ({ ...summary })),
|
|
474
|
+
};
|
|
475
|
+
writeJSON(res, 200, result(id, "succeeded", data));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (req.method !== "POST")
|
|
479
|
+
throw new BridgeError("TC_CONTROL_METHOD", 405);
|
|
480
|
+
if (req.headers["content-type"]?.split(";", 1)[0].trim().toLowerCase() !==
|
|
481
|
+
"application/json")
|
|
482
|
+
throw new BridgeError("TC_CONTROL_CONTENT_TYPE", 415);
|
|
483
|
+
if (req.headers["content-encoding"] &&
|
|
484
|
+
req.headers["content-encoding"] !== "identity")
|
|
485
|
+
throw new BridgeError("TC_CONTROL_CONTENT_ENCODING", 415);
|
|
486
|
+
const body = await readJSON(req, timeoutMs);
|
|
487
|
+
if (closed || res.destroyed)
|
|
488
|
+
throw new BridgeError("TC_CONTROL_CLOSED", 503);
|
|
489
|
+
if (route === "/__pelican/cancel") {
|
|
490
|
+
if (!record(body) ||
|
|
491
|
+
Object.keys(body).length !== 1 ||
|
|
492
|
+
typeof body.taskId !== "string" ||
|
|
493
|
+
!ID_PATTERN.test(body.taskId))
|
|
494
|
+
throw new BridgeError("TC_CONTROL_CANCEL_INVALID", 400);
|
|
495
|
+
const summary = history.get(body.taskId);
|
|
496
|
+
if (!summary)
|
|
497
|
+
throw new BridgeError("TC_CONTROL_TASK_UNKNOWN", 404);
|
|
498
|
+
const task = tasks.get(body.taskId);
|
|
499
|
+
const running = summary.status === "running";
|
|
500
|
+
const cancellable = running || summary.status === "queued";
|
|
501
|
+
if (task)
|
|
502
|
+
terminate(task, "cancelled", "TC_CONTROL_TASK_CANCELLED");
|
|
503
|
+
const cleaned = running ? await waitForCleanup() : true;
|
|
504
|
+
writeJSON(res, cleaned ? 200 : 503, result(id, cleaned ? "succeeded" : "failed", {
|
|
505
|
+
taskId: body.taskId,
|
|
506
|
+
status: summary.status,
|
|
507
|
+
cancelled: cancellable,
|
|
508
|
+
stateReset: running,
|
|
509
|
+
sessionNeedsReset: needsReset,
|
|
510
|
+
generation,
|
|
511
|
+
}, cleaned
|
|
512
|
+
? []
|
|
513
|
+
: [
|
|
514
|
+
diagnostic("TC_CONTROL_CLEANUP_PENDING", "Owned browser cleanup did not complete; a fresh session is blocked."),
|
|
515
|
+
]));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (route === "/__pelican/session") {
|
|
519
|
+
if (!record(body) ||
|
|
520
|
+
Object.keys(body).length !== 1 ||
|
|
521
|
+
body.action !== "reset")
|
|
522
|
+
throw new BridgeError("TC_CONTROL_SESSION_INVALID", 400);
|
|
523
|
+
const ok = await resetSession();
|
|
524
|
+
writeJSON(res, ok ? 200 : 503, result(id, ok ? "succeeded" : "failed", state(), ok
|
|
525
|
+
? []
|
|
526
|
+
: [
|
|
527
|
+
diagnostic("TC_CONTROL_CLEANUP_PENDING", "Cleanup has not completed; the old state is discarded and new commands remain blocked."),
|
|
528
|
+
]));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
let command;
|
|
532
|
+
try {
|
|
533
|
+
command = parseCommand(body);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
throw new BridgeError("TC_CONTROL_COMMAND_INVALID", 400);
|
|
537
|
+
}
|
|
538
|
+
const headerId = req.headers["x-pelican-task-id"];
|
|
539
|
+
if (headerId !== undefined &&
|
|
540
|
+
(typeof headerId !== "string" || !ID_PATTERN.test(headerId)))
|
|
541
|
+
throw new BridgeError("TC_CONTROL_TASK_ID_INVALID", 400);
|
|
542
|
+
if (history.has(id))
|
|
543
|
+
throw new BridgeError("TC_CONTROL_TASK_DUPLICATE", 409);
|
|
544
|
+
if (needsReset || resetting) {
|
|
545
|
+
writeJSON(res, 409, failed(id, "TC_CONTROL_SESSION_RESET_REQUIRED", "The previous state is discarded. Explicitly reset the session before starting a fresh one.", state()));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (queue.filter((task) => task.summary.status === "queued").length >=
|
|
549
|
+
QUEUE_LIMIT)
|
|
550
|
+
throw new BridgeError("TC_CONTROL_QUEUE_FULL", 429);
|
|
551
|
+
prune();
|
|
552
|
+
let finish;
|
|
553
|
+
const task = {
|
|
554
|
+
summary: {
|
|
555
|
+
taskId: id,
|
|
556
|
+
status: "queued",
|
|
557
|
+
commandType: command.type,
|
|
558
|
+
generation,
|
|
559
|
+
createdAt: Date.now(),
|
|
560
|
+
},
|
|
561
|
+
command,
|
|
562
|
+
done: new Promise((resolve) => {
|
|
563
|
+
finish = resolve;
|
|
564
|
+
}),
|
|
565
|
+
finish: (value) => finish(value),
|
|
566
|
+
};
|
|
567
|
+
tasks.set(id, task);
|
|
568
|
+
history.set(id, task.summary);
|
|
569
|
+
queue.push(task);
|
|
570
|
+
task.timer = setTimeout(() => terminate(task, "timed_out", task.summary.status === "queued"
|
|
571
|
+
? "TC_CONTROL_QUEUE_TIMEOUT"
|
|
572
|
+
: "TC_CONTROL_TASK_TIMEOUT"), timeoutMs);
|
|
573
|
+
const disconnected = () => {
|
|
574
|
+
if (!res.writableEnded)
|
|
575
|
+
terminate(task, "cancelled", "TC_CONTROL_CLIENT_DISCONNECTED");
|
|
576
|
+
};
|
|
577
|
+
res.once("close", disconnected);
|
|
578
|
+
void drain();
|
|
579
|
+
const payload = await task.done;
|
|
580
|
+
res.off("close", disconnected);
|
|
581
|
+
writeJSON(res, payload.status === "timed_out"
|
|
582
|
+
? 504
|
|
583
|
+
: payload.status === "failed"
|
|
584
|
+
? 422
|
|
585
|
+
: 200, payload);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
const known = error instanceof BridgeError
|
|
589
|
+
? error
|
|
590
|
+
: new BridgeError("TC_CONTROL_REQUEST_FAILED", 500);
|
|
591
|
+
writeJSON(res, known.httpStatus, failed(id, known.code, "Control request could not be completed. Check the command, request limits and local development server."));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const middleware = (req, res, next) => {
|
|
595
|
+
const route = req.url?.split("?", 1)[0];
|
|
596
|
+
if (![
|
|
597
|
+
"/__pelican/control",
|
|
598
|
+
"/__pelican/status",
|
|
599
|
+
"/__pelican/cancel",
|
|
600
|
+
"/__pelican/session",
|
|
601
|
+
].includes(route ?? "")) {
|
|
602
|
+
next();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
void handle(req, res, route);
|
|
606
|
+
};
|
|
607
|
+
// Prepend so GET status cannot be swallowed by Vite's already-installed SPA fallback.
|
|
608
|
+
const layer = { route: "", handle: middleware };
|
|
609
|
+
server.middlewares.stack.unshift(layer);
|
|
610
|
+
const onServerClose = () => {
|
|
611
|
+
void close().catch(() => undefined);
|
|
612
|
+
};
|
|
613
|
+
server.httpServer.once("close", onServerClose);
|
|
614
|
+
function close() {
|
|
615
|
+
if (closePromise)
|
|
616
|
+
return closePromise;
|
|
617
|
+
closed = true;
|
|
618
|
+
if (active?.summary.status === "running")
|
|
619
|
+
terminate(active, "cancelled", "TC_CONTROL_CLOSED");
|
|
620
|
+
else
|
|
621
|
+
discard();
|
|
622
|
+
const index = server.middlewares.stack.indexOf(layer);
|
|
623
|
+
if (index >= 0)
|
|
624
|
+
server.middlewares.stack.splice(index, 1);
|
|
625
|
+
server.httpServer?.off("close", onServerClose);
|
|
626
|
+
closePromise = waitForCleanup().then((ok) => {
|
|
627
|
+
if (!ok)
|
|
628
|
+
throw new Error("TC_CONTROL_CLEANUP_PENDING: Owned browser cleanup did not complete.");
|
|
629
|
+
});
|
|
630
|
+
return closePromise;
|
|
631
|
+
}
|
|
632
|
+
return { close };
|
|
633
|
+
}
|