castle-web-sdk 0.4.5 → 0.4.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/src/runtime.ts DELETED
@@ -1,451 +0,0 @@
1
- import {
2
- CASTLE_SDK_PROTOCOL,
3
- type CommandName,
4
- type CommandParams,
5
- type CommandResponseEnvelope,
6
- } from "./commands";
7
- import { getCastleEmbed, isEdit } from "./context";
8
-
9
- export const CARD_RATIO = 5 / 7;
10
-
11
- interface PendingRequest {
12
- resolve: (msg: LocalResponse) => void;
13
- reject: (err: Error) => void;
14
- timeout: ReturnType<typeof setTimeout>;
15
- }
16
-
17
- interface OutgoingMessage {
18
- type: string;
19
- [key: string]: unknown;
20
- }
21
-
22
- interface LocalResponse {
23
- type: string;
24
- requestId?: string;
25
- ok?: boolean;
26
- error?: string;
27
- [key: string]: unknown;
28
- }
29
-
30
- interface IncomingMessage {
31
- type: string;
32
- requestId?: string;
33
- [key: string]: unknown;
34
- }
35
-
36
- interface WsPortResponse {
37
- port?: number;
38
- path?: string;
39
- }
40
-
41
- let ws: WebSocket | null = null;
42
- let logBuffer: OutgoingMessage[] = [];
43
- let nextRequestId = 1;
44
- const pendingRequests = new Map<string, PendingRequest>();
45
-
46
- // Local-dev command channel: the `castle-web serve` dev server is the host, so
47
- // SDK commands ride the same websocket runtime.ts already uses for
48
- // logs/screenshots/restart. Correlated by requestId, separate from the
49
- // screenshot/write_file request map above.
50
- const COMMAND_TIMEOUT_MS = 15000;
51
- const SOCKET_WAIT_TIMEOUT_MS = 10000;
52
- const pendingCommands = new Map<
53
- string,
54
- (env: CommandResponseEnvelope) => void
55
- >();
56
-
57
- const origLog = console.log;
58
- const origWarn = console.warn;
59
- const origError = console.error;
60
-
61
- // Indirected dynamic import so deck bundlers don't statically rewrite it.
62
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
63
- const dynamicImport = new Function("u", "return import(u)") as (
64
- url: string,
65
- ) => Promise<unknown>;
66
-
67
- export function setup(): void {
68
- interceptConsole();
69
- connectLocal();
70
- initPlayCard();
71
- }
72
-
73
- export function writeFile(
74
- path: string,
75
- contents: string,
76
- ): Promise<LocalResponse> {
77
- return sendLocalRequest({
78
- type: "write_file",
79
- path,
80
- contents,
81
- });
82
- }
83
-
84
- export function initCard(): HTMLDivElement {
85
- const style = document.createElement("style");
86
- style.textContent = `
87
- * { margin: 0; padding: 0; box-sizing: border-box; }
88
- html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
89
- body { display: flex; align-items: center; justify-content: center; }
90
- #castle-card {
91
- position: relative;
92
- border-radius: 4% / calc(4% * (5 / 7));
93
- overflow: hidden;
94
- background: #121213;
95
- }
96
- `;
97
- document.head.appendChild(style);
98
-
99
- const card = document.createElement("div");
100
- card.id = "castle-card";
101
- document.body.appendChild(card);
102
-
103
- function resize(): void {
104
- if (getCastleEmbed()?.feed === true) {
105
- card.style.width = "100vw";
106
- card.style.height = "100vh";
107
- return;
108
- }
109
- const { w, h } = computeCardSize();
110
- card.style.width = w + "px";
111
- card.style.height = h + "px";
112
- }
113
- resize();
114
- window.addEventListener("resize", resize);
115
-
116
- return card;
117
- }
118
-
119
- // Constrains whatever the deck renders into #root to a centered 5:7 card.
120
- // The mobile feed host card-sizes its WebView itself, and the editor needs the
121
- // full viewport, so the card shell applies only to standalone play.
122
- function initPlayCard(): void {
123
- if (isEdit()) return;
124
- if (getCastleEmbed()?.feed === true) return;
125
-
126
- const style = document.createElement("style");
127
- style.textContent = `
128
- html, body { background: #000; }
129
- #root > * {
130
- position: fixed !important;
131
- inset: auto !important;
132
- left: 50% !important;
133
- top: 50% !important;
134
- transform: translate(-50%, -50%) !important;
135
- width: var(--castle-card-w, 100vw) !important;
136
- height: var(--castle-card-h, 100vh) !important;
137
- border-radius: 4% / calc(4% * (5 / 7)) !important;
138
- overflow: hidden !important;
139
- }
140
- `;
141
- document.head.appendChild(style);
142
-
143
- function resize(): void {
144
- const { w, h } = computeCardSize();
145
- document.documentElement.style.setProperty("--castle-card-w", w + "px");
146
- document.documentElement.style.setProperty("--castle-card-h", h + "px");
147
- }
148
- resize();
149
- window.addEventListener("resize", resize);
150
- }
151
-
152
- function computeCardSize(): { w: number; h: number } {
153
- const maxW = 450;
154
- const maxH = 630;
155
- const pad = 20;
156
- const aw = window.innerWidth - pad * 2;
157
- const ah = window.innerHeight - pad * 2;
158
- let w: number;
159
- let h: number;
160
- if (aw / ah < CARD_RATIO) {
161
- w = Math.min(aw, maxW);
162
- h = w / CARD_RATIO;
163
- } else {
164
- h = Math.min(ah, maxH);
165
- w = h * CARD_RATIO;
166
- }
167
- return { w, h };
168
- }
169
-
170
- function sendMsg(msg: OutgoingMessage): void {
171
- if (ws && ws.readyState === WebSocket.OPEN) {
172
- ws.send(JSON.stringify(msg));
173
- } else {
174
- logBuffer.push(msg);
175
- }
176
- }
177
-
178
- function sendLocalRequest(msg: OutgoingMessage): Promise<LocalResponse> {
179
- if (!ws || ws.readyState !== WebSocket.OPEN) {
180
- return Promise.reject(new Error("Castle local CLI is not connected."));
181
- }
182
-
183
- const requestId = `req_${nextRequestId++}`;
184
- const request = { ...msg, requestId };
185
- return new Promise<LocalResponse>((resolve, reject) => {
186
- const timeout = setTimeout(() => {
187
- pendingRequests.delete(requestId);
188
- reject(new Error(`Timed out waiting for ${msg.type}.`));
189
- }, 10000);
190
- pendingRequests.set(requestId, { resolve, reject, timeout });
191
- ws!.send(JSON.stringify(request));
192
- });
193
- }
194
-
195
- // Send an SDK command to the dev server and resolve with the raw response
196
- // envelope (ok/data/error). transport.ts interprets it — error reconstruction
197
- // stays uniform across all three channels there. Waits for the socket to open
198
- // so a command issued during startup isn't dropped.
199
- export function sendLocalCommand<C extends CommandName>(
200
- command: C,
201
- params: CommandParams[C],
202
- ): Promise<CommandResponseEnvelope> {
203
- const requestId = `cmd_${nextRequestId++}`;
204
- return new Promise<CommandResponseEnvelope>((resolve, reject) => {
205
- const timeout = setTimeout(() => {
206
- pendingCommands.delete(requestId);
207
- reject(new Error(`Timed out waiting for command ${command}.`));
208
- }, COMMAND_TIMEOUT_MS);
209
- pendingCommands.set(requestId, (env) => {
210
- clearTimeout(timeout);
211
- pendingCommands.delete(requestId);
212
- resolve(env);
213
- });
214
- waitForSocket()
215
- .then((socket) => {
216
- socket.send(
217
- JSON.stringify({
218
- type: "castle_command",
219
- castleSdk: CASTLE_SDK_PROTOCOL,
220
- requestId,
221
- command,
222
- params,
223
- }),
224
- );
225
- })
226
- .catch((error: unknown) => {
227
- clearTimeout(timeout);
228
- pendingCommands.delete(requestId);
229
- reject(error instanceof Error ? error : new Error(String(error)));
230
- });
231
- });
232
- }
233
-
234
- function waitForSocket(): Promise<WebSocket> {
235
- if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(ws);
236
- return new Promise<WebSocket>((resolve, reject) => {
237
- const start = Date.now();
238
- const poll = setInterval(() => {
239
- if (ws && ws.readyState === WebSocket.OPEN) {
240
- clearInterval(poll);
241
- resolve(ws);
242
- } else if (Date.now() - start > SOCKET_WAIT_TIMEOUT_MS) {
243
- clearInterval(poll);
244
- reject(new Error("Castle dev server is not connected."));
245
- }
246
- }, 100);
247
- });
248
- }
249
-
250
- function resolveLocalCommand(msg: CommandResponseEnvelope): void {
251
- const pending = pendingCommands.get(msg.requestId);
252
- if (pending) pending(msg);
253
- }
254
-
255
- function resolveLocalRequest(msg: LocalResponse): boolean {
256
- if (!msg.requestId) return false;
257
- const pending = pendingRequests.get(msg.requestId);
258
- if (!pending) return false;
259
- clearTimeout(pending.timeout);
260
- pendingRequests.delete(msg.requestId);
261
- if (msg.ok) {
262
- pending.resolve(msg);
263
- } else {
264
- pending.reject(new Error(msg.error || "Castle local request failed."));
265
- }
266
- return true;
267
- }
268
-
269
- function interceptConsole(): void {
270
- console.log = (...args: unknown[]): void => {
271
- origLog(...args);
272
- sendMsg({ type: "log", level: "log", msg: formatConsoleArgs(args) });
273
- };
274
- console.warn = (...args: unknown[]): void => {
275
- origWarn(...args);
276
- sendMsg({ type: "log", level: "warn", msg: formatConsoleArgs(args) });
277
- };
278
- console.error = (...args: unknown[]): void => {
279
- origError(...args);
280
- sendMsg({ type: "log", level: "error", msg: formatConsoleArgs(args) });
281
- };
282
- }
283
-
284
- function formatConsoleArgs(args: unknown[]): string {
285
- return args
286
- .map((arg) => {
287
- if (typeof arg === "string") return arg;
288
- if (arg instanceof Error) return arg.stack || arg.message;
289
- try {
290
- const json = JSON.stringify(arg);
291
- return json ?? String(arg);
292
- } catch {
293
- return String(arg);
294
- }
295
- })
296
- .join(" ");
297
- }
298
-
299
- async function captureWithHtml2Canvas(
300
- target: HTMLElement,
301
- ): Promise<string | null> {
302
- try {
303
- const mod = (await dynamicImport("https://esm.sh/html2canvas")) as {
304
- default: (
305
- el: HTMLElement,
306
- opts: Record<string, unknown>,
307
- ) => Promise<HTMLCanvasElement>;
308
- };
309
- const c = await mod.default(target, {
310
- backgroundColor: null,
311
- scale: devicePixelRatio,
312
- useCORS: true,
313
- });
314
- return c.toDataURL("image/png");
315
- } catch {
316
- return null;
317
- }
318
- }
319
-
320
- async function captureScreenshot(): Promise<string | null> {
321
- const card = document.getElementById("castle-card");
322
- const canvas = document.querySelector("canvas");
323
- if (document.body?.dataset.castleScreenshotTarget === "viewport") {
324
- const viewportCapture = await captureWithHtml2Canvas(document.body);
325
- if (viewportCapture) return viewportCapture;
326
- }
327
- if (card && canvas) {
328
- const cardRect = card.getBoundingClientRect();
329
- const c = document.createElement("canvas");
330
- c.width = cardRect.width * devicePixelRatio;
331
- c.height = cardRect.height * devicePixelRatio;
332
- const ctx = c.getContext("2d")!;
333
- const canvasRect = canvas.getBoundingClientRect();
334
- const dx = (canvasRect.left - cardRect.left) * devicePixelRatio;
335
- const dy = (canvasRect.top - cardRect.top) * devicePixelRatio;
336
- ctx.drawImage(
337
- canvas,
338
- dx,
339
- dy,
340
- canvasRect.width * devicePixelRatio,
341
- canvasRect.height * devicePixelRatio,
342
- );
343
- return c.toDataURL("image/png");
344
- }
345
- if (canvas) return canvas.toDataURL("image/png");
346
- return captureWithHtml2Canvas(card || document.body);
347
- }
348
-
349
- function connectLocal(): void {
350
- fetch("/__castle/ws-port")
351
- .then((r) => r.json() as Promise<WsPortResponse>)
352
- .then(({ port, path }) => {
353
- if (!port && !path) return;
354
- const socket = new WebSocket(
355
- path ? localWsUrl(path) : `ws://localhost:${port}`,
356
- );
357
- socket.onopen = (): void => {
358
- ws = socket;
359
- for (const msg of logBuffer) ws.send(JSON.stringify(msg));
360
- logBuffer = [];
361
- };
362
- socket.onmessage = (evt: MessageEvent): void => {
363
- try {
364
- const msg = JSON.parse(evt.data as string) as IncomingMessage;
365
- handleLocalMessage(msg);
366
- } catch {
367
- // ignore malformed messages
368
- }
369
- };
370
- socket.onclose = (): void => {
371
- ws = null;
372
- for (const [requestId, pending] of pendingRequests) {
373
- clearTimeout(pending.timeout);
374
- pending.reject(new Error("Castle local CLI disconnected."));
375
- pendingRequests.delete(requestId);
376
- }
377
- setTimeout(connectLocal, 2000);
378
- };
379
- socket.onerror = (): void => socket.close();
380
- })
381
- .catch(() => {});
382
- }
383
-
384
- function handleLocalMessage(msg: IncomingMessage): void {
385
- if (msg.type === "screenshot_request") {
386
- void captureScreenshot()
387
- .then((data) => {
388
- if (data) {
389
- sendMsg({
390
- type: "screenshot_response",
391
- requestId: msg.requestId,
392
- data,
393
- });
394
- return;
395
- }
396
- sendMsg({
397
- type: "screenshot_response",
398
- requestId: msg.requestId,
399
- ok: false,
400
- error: "Could not capture screenshot.",
401
- });
402
- })
403
- .catch((error: unknown) => {
404
- sendMsg({
405
- type: "screenshot_response",
406
- requestId: msg.requestId,
407
- ok: false,
408
- error: error instanceof Error ? error.message : "Could not capture screenshot.",
409
- });
410
- });
411
- } else if (msg.type === "restart") {
412
- scheduleRestart();
413
- } else if (msg.type === "write_file_response") {
414
- resolveLocalRequest(msg);
415
- } else if (msg.type === "castle_command_response") {
416
- resolveLocalCommand(msg as unknown as CommandResponseEnvelope);
417
- }
418
- }
419
-
420
- // Restart (from `castle-web restart` / task agents) is debounced so a burst
421
- // of reload requests -- several tasks finishing close together -- produces
422
- // one reload. Before reloading, registered hooks run (the kit editor flushes
423
- // its debounced unsaved edits there) so in-flight work isn't lost.
424
- const RESTART_DEBOUNCE_MS = 1500;
425
- let restartTimer: ReturnType<typeof setTimeout> | null = null;
426
- const beforeRestartHooks = new Set<() => void | Promise<void>>();
427
-
428
- export function onBeforeRestart(hook: () => void | Promise<void>): () => void {
429
- beforeRestartHooks.add(hook);
430
- return () => beforeRestartHooks.delete(hook);
431
- }
432
-
433
- function scheduleRestart(): void {
434
- if (restartTimer !== null) clearTimeout(restartTimer);
435
- restartTimer = setTimeout(() => {
436
- void (async () => {
437
- try {
438
- await Promise.all([...beforeRestartHooks].map(async (hook) => hook()));
439
- } catch {
440
- // a failed flush shouldn't block the reload
441
- }
442
- location.reload();
443
- })();
444
- }, RESTART_DEBOUNCE_MS);
445
- }
446
-
447
- function localWsUrl(path: string): string {
448
- const url = new URL(path, location.href);
449
- url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
450
- return url.href;
451
- }