castle-web-sdk 0.4.0 → 0.4.2

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 ADDED
@@ -0,0 +1,345 @@
1
+ import { getCastleEmbed, isEdit } from "./context";
2
+
3
+ export const CARD_RATIO = 5 / 7;
4
+
5
+ interface PendingRequest {
6
+ resolve: (msg: LocalResponse) => void;
7
+ reject: (err: Error) => void;
8
+ timeout: ReturnType<typeof setTimeout>;
9
+ }
10
+
11
+ interface OutgoingMessage {
12
+ type: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface LocalResponse {
17
+ type: string;
18
+ requestId?: string;
19
+ ok?: boolean;
20
+ error?: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ interface IncomingMessage {
25
+ type: string;
26
+ requestId?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ interface WsPortResponse {
31
+ port?: number;
32
+ path?: string;
33
+ }
34
+
35
+ let ws: WebSocket | null = null;
36
+ let logBuffer: OutgoingMessage[] = [];
37
+ let nextRequestId = 1;
38
+ const pendingRequests = new Map<string, PendingRequest>();
39
+
40
+ const origLog = console.log;
41
+ const origWarn = console.warn;
42
+ const origError = console.error;
43
+
44
+ // Indirected dynamic import so deck bundlers don't statically rewrite it.
45
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
46
+ const dynamicImport = new Function("u", "return import(u)") as (
47
+ url: string,
48
+ ) => Promise<unknown>;
49
+
50
+ export function setup(): void {
51
+ interceptConsole();
52
+ connectLocal();
53
+ initPlayCard();
54
+ }
55
+
56
+ export function writeFile(
57
+ path: string,
58
+ contents: string,
59
+ ): Promise<LocalResponse> {
60
+ return sendLocalRequest({
61
+ type: "write_file",
62
+ path,
63
+ contents,
64
+ });
65
+ }
66
+
67
+ export function initCard(): HTMLDivElement {
68
+ const style = document.createElement("style");
69
+ style.textContent = `
70
+ * { margin: 0; padding: 0; box-sizing: border-box; }
71
+ html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
72
+ body { display: flex; align-items: center; justify-content: center; }
73
+ #castle-card {
74
+ position: relative;
75
+ border-radius: 4% / calc(4% * (5 / 7));
76
+ overflow: hidden;
77
+ background: #121213;
78
+ }
79
+ `;
80
+ document.head.appendChild(style);
81
+
82
+ const card = document.createElement("div");
83
+ card.id = "castle-card";
84
+ document.body.appendChild(card);
85
+
86
+ function resize(): void {
87
+ if (getCastleEmbed()?.feed === true) {
88
+ card.style.width = "100vw";
89
+ card.style.height = "100vh";
90
+ return;
91
+ }
92
+ const { w, h } = computeCardSize();
93
+ card.style.width = w + "px";
94
+ card.style.height = h + "px";
95
+ }
96
+ resize();
97
+ window.addEventListener("resize", resize);
98
+
99
+ return card;
100
+ }
101
+
102
+ // Constrains whatever the deck renders into #root to a centered 5:7 card.
103
+ // The mobile feed host card-sizes its WebView itself, and the editor needs the
104
+ // full viewport, so the card shell applies only to standalone play.
105
+ function initPlayCard(): void {
106
+ if (isEdit()) return;
107
+ if (getCastleEmbed()?.feed === true) return;
108
+
109
+ const style = document.createElement("style");
110
+ style.textContent = `
111
+ html, body { background: #000; }
112
+ #root > * {
113
+ position: fixed !important;
114
+ inset: auto !important;
115
+ left: 50% !important;
116
+ top: 50% !important;
117
+ transform: translate(-50%, -50%) !important;
118
+ width: var(--castle-card-w, 100vw) !important;
119
+ height: var(--castle-card-h, 100vh) !important;
120
+ border-radius: 4% / calc(4% * (5 / 7)) !important;
121
+ overflow: hidden !important;
122
+ }
123
+ `;
124
+ document.head.appendChild(style);
125
+
126
+ function resize(): void {
127
+ const { w, h } = computeCardSize();
128
+ document.documentElement.style.setProperty("--castle-card-w", w + "px");
129
+ document.documentElement.style.setProperty("--castle-card-h", h + "px");
130
+ }
131
+ resize();
132
+ window.addEventListener("resize", resize);
133
+ }
134
+
135
+ function computeCardSize(): { w: number; h: number } {
136
+ const maxW = 450;
137
+ const maxH = 630;
138
+ const pad = 20;
139
+ const aw = window.innerWidth - pad * 2;
140
+ const ah = window.innerHeight - pad * 2;
141
+ let w: number;
142
+ let h: number;
143
+ if (aw / ah < CARD_RATIO) {
144
+ w = Math.min(aw, maxW);
145
+ h = w / CARD_RATIO;
146
+ } else {
147
+ h = Math.min(ah, maxH);
148
+ w = h * CARD_RATIO;
149
+ }
150
+ return { w, h };
151
+ }
152
+
153
+ function sendMsg(msg: OutgoingMessage): void {
154
+ if (ws && ws.readyState === WebSocket.OPEN) {
155
+ ws.send(JSON.stringify(msg));
156
+ } else {
157
+ logBuffer.push(msg);
158
+ }
159
+ }
160
+
161
+ function sendLocalRequest(msg: OutgoingMessage): Promise<LocalResponse> {
162
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
163
+ return Promise.reject(new Error("Castle local CLI is not connected."));
164
+ }
165
+
166
+ const requestId = `req_${nextRequestId++}`;
167
+ const request = { ...msg, requestId };
168
+ return new Promise<LocalResponse>((resolve, reject) => {
169
+ const timeout = setTimeout(() => {
170
+ pendingRequests.delete(requestId);
171
+ reject(new Error(`Timed out waiting for ${msg.type}.`));
172
+ }, 10000);
173
+ pendingRequests.set(requestId, { resolve, reject, timeout });
174
+ ws!.send(JSON.stringify(request));
175
+ });
176
+ }
177
+
178
+ function resolveLocalRequest(msg: LocalResponse): boolean {
179
+ if (!msg.requestId) return false;
180
+ const pending = pendingRequests.get(msg.requestId);
181
+ if (!pending) return false;
182
+ clearTimeout(pending.timeout);
183
+ pendingRequests.delete(msg.requestId);
184
+ if (msg.ok) {
185
+ pending.resolve(msg);
186
+ } else {
187
+ pending.reject(new Error(msg.error || "Castle local request failed."));
188
+ }
189
+ return true;
190
+ }
191
+
192
+ function interceptConsole(): void {
193
+ console.log = (...args: unknown[]): void => {
194
+ origLog(...args);
195
+ sendMsg({ type: "log", level: "log", msg: formatConsoleArgs(args) });
196
+ };
197
+ console.warn = (...args: unknown[]): void => {
198
+ origWarn(...args);
199
+ sendMsg({ type: "log", level: "warn", msg: formatConsoleArgs(args) });
200
+ };
201
+ console.error = (...args: unknown[]): void => {
202
+ origError(...args);
203
+ sendMsg({ type: "log", level: "error", msg: formatConsoleArgs(args) });
204
+ };
205
+ }
206
+
207
+ function formatConsoleArgs(args: unknown[]): string {
208
+ return args
209
+ .map((arg) => {
210
+ if (typeof arg === "string") return arg;
211
+ if (arg instanceof Error) return arg.stack || arg.message;
212
+ try {
213
+ const json = JSON.stringify(arg);
214
+ return json ?? String(arg);
215
+ } catch {
216
+ return String(arg);
217
+ }
218
+ })
219
+ .join(" ");
220
+ }
221
+
222
+ async function captureWithHtml2Canvas(
223
+ target: HTMLElement,
224
+ ): Promise<string | null> {
225
+ try {
226
+ const mod = (await dynamicImport("https://esm.sh/html2canvas")) as {
227
+ default: (
228
+ el: HTMLElement,
229
+ opts: Record<string, unknown>,
230
+ ) => Promise<HTMLCanvasElement>;
231
+ };
232
+ const c = await mod.default(target, {
233
+ backgroundColor: null,
234
+ scale: devicePixelRatio,
235
+ useCORS: true,
236
+ });
237
+ return c.toDataURL("image/png");
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ async function captureScreenshot(): Promise<string | null> {
244
+ const card = document.getElementById("castle-card");
245
+ const canvas = document.querySelector("canvas");
246
+ if (document.body?.dataset.castleScreenshotTarget === "viewport") {
247
+ const viewportCapture = await captureWithHtml2Canvas(document.body);
248
+ if (viewportCapture) return viewportCapture;
249
+ }
250
+ if (card && canvas) {
251
+ const cardRect = card.getBoundingClientRect();
252
+ const c = document.createElement("canvas");
253
+ c.width = cardRect.width * devicePixelRatio;
254
+ c.height = cardRect.height * devicePixelRatio;
255
+ const ctx = c.getContext("2d")!;
256
+ const canvasRect = canvas.getBoundingClientRect();
257
+ const dx = (canvasRect.left - cardRect.left) * devicePixelRatio;
258
+ const dy = (canvasRect.top - cardRect.top) * devicePixelRatio;
259
+ ctx.drawImage(
260
+ canvas,
261
+ dx,
262
+ dy,
263
+ canvasRect.width * devicePixelRatio,
264
+ canvasRect.height * devicePixelRatio,
265
+ );
266
+ return c.toDataURL("image/png");
267
+ }
268
+ if (canvas) return canvas.toDataURL("image/png");
269
+ return captureWithHtml2Canvas(card || document.body);
270
+ }
271
+
272
+ function connectLocal(): void {
273
+ fetch("/__castle/ws-port")
274
+ .then((r) => r.json() as Promise<WsPortResponse>)
275
+ .then(({ port, path }) => {
276
+ if (!port && !path) return;
277
+ const socket = new WebSocket(
278
+ path ? localWsUrl(path) : `ws://localhost:${port}`,
279
+ );
280
+ socket.onopen = (): void => {
281
+ ws = socket;
282
+ for (const msg of logBuffer) ws.send(JSON.stringify(msg));
283
+ logBuffer = [];
284
+ };
285
+ socket.onmessage = (evt: MessageEvent): void => {
286
+ try {
287
+ const msg = JSON.parse(evt.data as string) as IncomingMessage;
288
+ handleLocalMessage(msg);
289
+ } catch {
290
+ // ignore malformed messages
291
+ }
292
+ };
293
+ socket.onclose = (): void => {
294
+ ws = null;
295
+ for (const [requestId, pending] of pendingRequests) {
296
+ clearTimeout(pending.timeout);
297
+ pending.reject(new Error("Castle local CLI disconnected."));
298
+ pendingRequests.delete(requestId);
299
+ }
300
+ setTimeout(connectLocal, 2000);
301
+ };
302
+ socket.onerror = (): void => socket.close();
303
+ })
304
+ .catch(() => {});
305
+ }
306
+
307
+ function handleLocalMessage(msg: IncomingMessage): void {
308
+ if (msg.type === "screenshot_request") {
309
+ void captureScreenshot()
310
+ .then((data) => {
311
+ if (data) {
312
+ sendMsg({
313
+ type: "screenshot_response",
314
+ requestId: msg.requestId,
315
+ data,
316
+ });
317
+ return;
318
+ }
319
+ sendMsg({
320
+ type: "screenshot_response",
321
+ requestId: msg.requestId,
322
+ ok: false,
323
+ error: "Could not capture screenshot.",
324
+ });
325
+ })
326
+ .catch((error: unknown) => {
327
+ sendMsg({
328
+ type: "screenshot_response",
329
+ requestId: msg.requestId,
330
+ ok: false,
331
+ error: error instanceof Error ? error.message : "Could not capture screenshot.",
332
+ });
333
+ });
334
+ } else if (msg.type === "restart") {
335
+ location.reload();
336
+ } else if (msg.type === "write_file_response") {
337
+ resolveLocalRequest(msg);
338
+ }
339
+ }
340
+
341
+ function localWsUrl(path: string): string {
342
+ const url = new URL(path, location.href);
343
+ url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
344
+ return url.href;
345
+ }