@illuma-ai/code-sandbox 1.0.0

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/dist/__sw__.js ADDED
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Nodepod Service Worker — proxies requests to virtual servers.
3
+ * Version: 2 (cross-origin passthrough + prefix stripping)
4
+ *
5
+ * Intercepts:
6
+ * /__virtual__/{port}/{path} — virtual server API
7
+ * /__preview__/{port}/{path} — preview iframe navigation
8
+ * Any request from a client loaded via /__preview__/ — module imports etc.
9
+ *
10
+ * When an iframe navigates to /__preview__/{port}/, the SW records the
11
+ * resulting clientId. All subsequent requests from that client (including
12
+ * ES module imports like /@react-refresh) are intercepted and routed
13
+ * through the virtual server.
14
+ */
15
+
16
+ const SW_VERSION = 4;
17
+
18
+ let port = null;
19
+ let nextId = 1;
20
+ const pending = new Map();
21
+
22
+ // Maps clientId -> serverPort for preview iframes
23
+ const previewClients = new Map();
24
+
25
+ // User-injected script that runs before any page content in preview iframes.
26
+ // Set via postMessage({ type: "set-preview-script", script: "..." }) from main thread.
27
+ let previewScript = null;
28
+
29
+ // Watermark badge disabled — not needed for @ranger/code-sandbox.
30
+ let watermarkEnabled = false;
31
+
32
+ // auth token from init, checked on control messages
33
+ let authToken = null;
34
+
35
+ // ws bridge token, gets baked into the shim script
36
+ let wsToken = null;
37
+
38
+ // Standard MIME types by file extension — used as a safety net when
39
+ // the virtual server returns text/html (SPA fallback) or omits Content-Type
40
+ // for paths that are clearly not HTML.
41
+ const MIME_TYPES = {
42
+ ".js": "application/javascript",
43
+ ".mjs": "application/javascript",
44
+ ".cjs": "application/javascript",
45
+ ".ts": "application/javascript",
46
+ ".tsx": "application/javascript",
47
+ ".jsx": "application/javascript",
48
+ ".css": "text/css",
49
+ ".json": "application/json",
50
+ ".map": "application/json",
51
+ ".svg": "image/svg+xml",
52
+ ".png": "image/png",
53
+ ".jpg": "image/jpeg",
54
+ ".jpeg": "image/jpeg",
55
+ ".gif": "image/gif",
56
+ ".webp": "image/webp",
57
+ ".avif": "image/avif",
58
+ ".ico": "image/x-icon",
59
+ ".woff": "font/woff",
60
+ ".woff2": "font/woff2",
61
+ ".ttf": "font/ttf",
62
+ ".otf": "font/otf",
63
+ ".eot": "application/vnd.ms-fontobject",
64
+ ".wasm": "application/wasm",
65
+ ".mp4": "video/mp4",
66
+ ".webm": "video/webm",
67
+ ".mp3": "audio/mpeg",
68
+ ".ogg": "audio/ogg",
69
+ ".wav": "audio/wav",
70
+ ".txt": "text/plain",
71
+ ".xml": "application/xml",
72
+ ".pdf": "application/pdf",
73
+ ".yaml": "text/yaml",
74
+ ".yml": "text/yaml",
75
+ ".md": "text/markdown",
76
+ };
77
+
78
+ /**
79
+ * Infer correct MIME type for a response based on the request path.
80
+ * When a server's SPA fallback serves index.html (text/html) for paths that
81
+ * are clearly not HTML (e.g. .js, .css, .json files), the Content-Type is
82
+ * wrong. This corrects it based purely on the file extension in the URL.
83
+ */
84
+ function inferMimeType(path, responseHeaders) {
85
+ const ct =
86
+ responseHeaders["content-type"] || responseHeaders["Content-Type"] || "";
87
+
88
+ // If the server already set a non-HTML Content-Type, trust it
89
+ if (ct && !ct.includes("text/html")) {
90
+ return null; // no override needed
91
+ }
92
+
93
+ // Strip query string and hash for extension detection
94
+ const cleanPath = path.split("?")[0].split("#")[0];
95
+ const lastDot = cleanPath.lastIndexOf(".");
96
+ const ext = lastDot >= 0 ? cleanPath.slice(lastDot).toLowerCase() : "";
97
+
98
+ // Only override if the path has a known non-HTML extension
99
+ if (ext && MIME_TYPES[ext]) {
100
+ return MIME_TYPES[ext];
101
+ }
102
+
103
+ return null; // no override
104
+ }
105
+
106
+ // ── Lifecycle ──
107
+
108
+ self.addEventListener("install", () => {
109
+ self.skipWaiting();
110
+ });
111
+ self.addEventListener("activate", (event) => {
112
+ event.waitUntil(self.clients.claim());
113
+ });
114
+
115
+ // ── Message handling ──
116
+
117
+ self.addEventListener("message", (event) => {
118
+ const data = event.data;
119
+
120
+ // init sets up the port + grabs the auth token
121
+ if (data?.type === "init" && data.port) {
122
+ port = data.port;
123
+ port.onmessage = onPortMessage;
124
+ if (data.token) {
125
+ authToken = data.token;
126
+ }
127
+ return;
128
+ }
129
+
130
+ // everything else needs the right token
131
+ if (authToken && data?.token !== authToken) return;
132
+
133
+ // Allow main thread to register/unregister preview clients
134
+ if (data?.type === "register-preview") {
135
+ previewClients.set(data.clientId, data.serverPort);
136
+ }
137
+ if (data?.type === "unregister-preview") {
138
+ previewClients.delete(data.clientId);
139
+ }
140
+ if (data?.type === "set-preview-script") {
141
+ previewScript = data.script ?? null;
142
+ }
143
+ if (data?.type === "set-watermark") {
144
+ watermarkEnabled = !!data.enabled;
145
+ }
146
+ if (data?.type === "set-ws-token") {
147
+ wsToken = data.wsToken ?? null;
148
+ }
149
+ });
150
+
151
+ function onPortMessage(event) {
152
+ const msg = event.data;
153
+ if (msg.type === "response" && pending.has(msg.id)) {
154
+ const { resolve, reject } = pending.get(msg.id);
155
+ pending.delete(msg.id);
156
+ if (msg.error) reject(new Error(msg.error));
157
+ else resolve(msg.data);
158
+ }
159
+ }
160
+
161
+ // ── Fetch interception ──
162
+
163
+ self.addEventListener("fetch", (event) => {
164
+ const url = new URL(event.request.url);
165
+
166
+ // 1. Explicit /__virtual__/{port}/{path}
167
+ const virtualMatch = url.pathname.match(/^\/__virtual__\/(\d+)(\/.*)?$/);
168
+ if (virtualMatch) {
169
+ const serverPort = parseInt(virtualMatch[1], 10);
170
+ const path = (virtualMatch[2] || "/") + url.search;
171
+ event.respondWith(proxyToVirtualServer(event.request, serverPort, path));
172
+ return;
173
+ }
174
+
175
+ // 2. Explicit /__preview__/{port}/{path} — navigation or subresource
176
+ const previewMatch = url.pathname.match(/^\/__preview__\/(\d+)(\/.*)?$/);
177
+ if (previewMatch) {
178
+ const serverPort = parseInt(previewMatch[1], 10);
179
+ const path = (previewMatch[2] || "/") + url.search;
180
+
181
+ // Track the resulting client (for navigation requests) or current client
182
+ if (event.request.mode === "navigate") {
183
+ event.respondWith(
184
+ (async () => {
185
+ // resultingClientId is the client that will be created by this navigation
186
+ if (event.resultingClientId) {
187
+ previewClients.set(event.resultingClientId, serverPort);
188
+ }
189
+ return proxyToVirtualServer(event.request, serverPort, path);
190
+ })(),
191
+ );
192
+ } else {
193
+ event.respondWith(proxyToVirtualServer(event.request, serverPort, path));
194
+ }
195
+ return;
196
+ }
197
+
198
+ // 3. Request from a tracked preview client — route through virtual server.
199
+ // This catches module imports like /@react-refresh, /src/main.tsx, etc.
200
+ // Only intercept same-origin requests; let cross-origin requests
201
+ // (e.g. Google Fonts, external CDNs) pass through to the real server.
202
+ const clientId = event.clientId;
203
+ if (clientId && previewClients.has(clientId)) {
204
+ const host = url.hostname;
205
+ if (
206
+ host === "localhost" ||
207
+ host === "127.0.0.1" ||
208
+ host === "0.0.0.0" ||
209
+ host === self.location.hostname
210
+ ) {
211
+ const serverPort = previewClients.get(clientId);
212
+ // Strip /__preview__/{port} prefix if the browser resolved a relative URL
213
+ // against the preview page's location (e.g. /__preview__/3001.rsc → /.rsc,
214
+ // /__preview__/3001/foo → /foo)
215
+ let path = url.pathname;
216
+ const ppMatch = path.match(/^\/__preview__\/\d+(.*)?$/);
217
+ if (ppMatch) {
218
+ path = ppMatch[1] || "/";
219
+ if (path[0] !== "/") path = "/" + path;
220
+ }
221
+ path += url.search;
222
+ event.respondWith(
223
+ proxyToVirtualServer(event.request, serverPort, path, event.request),
224
+ );
225
+ return;
226
+ }
227
+ }
228
+
229
+ // 4. Fallback: check Referer header for /__preview__/ prefix.
230
+ // Handles edge cases where clientId might not be set.
231
+ // Only intercept same-origin requests (not cross-origin like Google Fonts).
232
+ const referer = event.request.referrer;
233
+ if (referer) {
234
+ try {
235
+ const refUrl = new URL(referer);
236
+ const refMatch = refUrl.pathname.match(/^\/__preview__\/(\d+)/);
237
+ if (refMatch) {
238
+ const host = url.hostname;
239
+ if (
240
+ host === "localhost" ||
241
+ host === "127.0.0.1" ||
242
+ host === "0.0.0.0" ||
243
+ host === self.location.hostname
244
+ ) {
245
+ const serverPort = parseInt(refMatch[1], 10);
246
+ // Strip /__preview__/{port} prefix if present
247
+ let path = url.pathname;
248
+ const ppMatch2 = path.match(/^\/__preview__\/\d+(.*)?$/);
249
+ if (ppMatch2) {
250
+ path = ppMatch2[1] || "/";
251
+ if (path[0] !== "/") path = "/" + path;
252
+ }
253
+ path += url.search;
254
+ // Also register this client for future requests
255
+ if (clientId) {
256
+ previewClients.set(clientId, serverPort);
257
+ }
258
+ event.respondWith(
259
+ proxyToVirtualServer(
260
+ event.request,
261
+ serverPort,
262
+ path,
263
+ event.request,
264
+ ),
265
+ );
266
+ return;
267
+ }
268
+ }
269
+ } catch {
270
+ // Invalid referer URL, ignore
271
+ }
272
+ }
273
+
274
+ // If nothing matched, let the browser handle it normally
275
+ });
276
+
277
+ // ── WebSocket shim for preview iframes ──
278
+ //
279
+ // Injected into HTML responses to override the browser's WebSocket constructor.
280
+ // Routes localhost WebSocket connections through BroadcastChannel "nodepod-ws"
281
+ // to the main thread's request-proxy, which dispatches upgrade events on the
282
+ // virtual HTTP server. Works with any framework/library, not specific to Vite.
283
+
284
+ function getWsShimScript() {
285
+ const tokenStr = wsToken ? JSON.stringify(wsToken) : "null";
286
+ return `<script>
287
+ (function() {
288
+ if (window.__nodepodWsShim) return;
289
+ window.__nodepodWsShim = true;
290
+ var NativeWS = window.WebSocket;
291
+ var bc = new BroadcastChannel("nodepod-ws");
292
+ var _wsToken = ${tokenStr};
293
+ var nextId = 0;
294
+ var active = {};
295
+
296
+ // Detect the virtual server port from the page URL.
297
+ // When loaded via /__preview__/{port}/, use that port for WS connections
298
+ // instead of the literal port from the WS URL (which is the host page's port).
299
+ var _previewPort = 0;
300
+ try {
301
+ var _m = location.pathname.match(/^\\/__preview__\\/(\\d+)/);
302
+ if (_m) _previewPort = parseInt(_m[1], 10);
303
+ } catch(e) {}
304
+
305
+ function NodepodWS(url, protocols) {
306
+ var parsed;
307
+ try { parsed = new URL(url, location.href); } catch(e) {
308
+ return new NativeWS(url, protocols);
309
+ }
310
+ // Only intercept localhost connections
311
+ var host = parsed.hostname;
312
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "0.0.0.0") {
313
+ return new NativeWS(url, protocols);
314
+ }
315
+ var self = this;
316
+ var uid = "ws-iframe-" + (++nextId) + "-" + Math.random().toString(36).slice(2,8);
317
+ // Use the preview port (from /__preview__/{port}/) if available,
318
+ // otherwise fall back to the port from the WebSocket URL.
319
+ var port = _previewPort || parseInt(parsed.port) || (parsed.protocol === "wss:" ? 443 : 80);
320
+ var path = parsed.pathname + parsed.search;
321
+
322
+ self.url = url;
323
+ self.readyState = 0; // CONNECTING
324
+ self.protocol = "";
325
+ self.extensions = "";
326
+ self.bufferedAmount = 0;
327
+ self.binaryType = "blob";
328
+ self.onopen = null;
329
+ self.onclose = null;
330
+ self.onerror = null;
331
+ self.onmessage = null;
332
+ self._uid = uid;
333
+ self._listeners = {};
334
+
335
+ active[uid] = self;
336
+
337
+ bc.postMessage({
338
+ kind: "ws-connect",
339
+ uid: uid,
340
+ port: port,
341
+ path: path,
342
+ protocols: Array.isArray(protocols) ? protocols.join(",") : (protocols || ""),
343
+ token: _wsToken
344
+ });
345
+
346
+ // Timeout: if no ws-open within 5s, fire error
347
+ self._connectTimer = setTimeout(function() {
348
+ if (self.readyState === 0) {
349
+ self.readyState = 3;
350
+ var e = new Event("error");
351
+ self.onerror && self.onerror(e);
352
+ _emit(self, "error", e);
353
+ delete active[uid];
354
+ }
355
+ }, 5000);
356
+ }
357
+
358
+ function _emit(ws, evt, arg) {
359
+ var list = ws._listeners[evt];
360
+ if (!list) return;
361
+ for (var i = 0; i < list.length; i++) {
362
+ try { list[i].call(ws, arg); } catch(e) { /* ignore */ }
363
+ }
364
+ }
365
+
366
+ NodepodWS.prototype.addEventListener = function(evt, fn) {
367
+ if (!this._listeners[evt]) this._listeners[evt] = [];
368
+ this._listeners[evt].push(fn);
369
+ };
370
+ NodepodWS.prototype.removeEventListener = function(evt, fn) {
371
+ var list = this._listeners[evt];
372
+ if (!list) return;
373
+ this._listeners[evt] = list.filter(function(f) { return f !== fn; });
374
+ };
375
+ NodepodWS.prototype.dispatchEvent = function(evt) {
376
+ _emit(this, evt.type, evt);
377
+ return true;
378
+ };
379
+ NodepodWS.prototype.send = function(data) {
380
+ if (this.readyState !== 1) throw new Error("WebSocket is not open");
381
+ var type = "text";
382
+ var payload = data;
383
+ if (data instanceof ArrayBuffer) {
384
+ type = "binary";
385
+ payload = Array.from(new Uint8Array(data));
386
+ } else if (data instanceof Uint8Array) {
387
+ type = "binary";
388
+ payload = Array.from(data);
389
+ }
390
+ bc.postMessage({ kind: "ws-send", uid: this._uid, data: payload, type: type, token: _wsToken });
391
+ };
392
+ NodepodWS.prototype.close = function(code, reason) {
393
+ if (this.readyState >= 2) return;
394
+ this.readyState = 2;
395
+ bc.postMessage({ kind: "ws-close", uid: this._uid, code: code || 1000, reason: reason || "", token: _wsToken });
396
+ var self = this;
397
+ setTimeout(function() {
398
+ self.readyState = 3;
399
+ var e = new CloseEvent("close", { code: code || 1000, reason: reason || "", wasClean: true });
400
+ self.onclose && self.onclose(e);
401
+ _emit(self, "close", e);
402
+ delete active[self._uid];
403
+ }, 0);
404
+ };
405
+
406
+ NodepodWS.CONNECTING = 0;
407
+ NodepodWS.OPEN = 1;
408
+ NodepodWS.CLOSING = 2;
409
+ NodepodWS.CLOSED = 3;
410
+ NodepodWS.prototype.CONNECTING = 0;
411
+ NodepodWS.prototype.OPEN = 1;
412
+ NodepodWS.prototype.CLOSING = 2;
413
+ NodepodWS.prototype.CLOSED = 3;
414
+
415
+ bc.onmessage = function(ev) {
416
+ var d = ev.data;
417
+ if (!d || !d.uid) return;
418
+ // check bridge token
419
+ if (_wsToken && d.token !== _wsToken) return;
420
+ var ws = active[d.uid];
421
+ if (!ws) return;
422
+
423
+ if (d.kind === "ws-open") {
424
+ clearTimeout(ws._connectTimer);
425
+ ws.readyState = 1;
426
+ var e = new Event("open");
427
+ ws.onopen && ws.onopen(e);
428
+ _emit(ws, "open", e);
429
+ } else if (d.kind === "ws-message") {
430
+ var msgData;
431
+ if (d.type === "binary") {
432
+ msgData = new Uint8Array(d.data).buffer;
433
+ } else {
434
+ msgData = d.data;
435
+ }
436
+ var me = new MessageEvent("message", { data: msgData });
437
+ ws.onmessage && ws.onmessage(me);
438
+ _emit(ws, "message", me);
439
+ } else if (d.kind === "ws-closed") {
440
+ ws.readyState = 3;
441
+ clearTimeout(ws._connectTimer);
442
+ var ce = new CloseEvent("close", { code: d.code || 1000, reason: "", wasClean: true });
443
+ ws.onclose && ws.onclose(ce);
444
+ _emit(ws, "close", ce);
445
+ delete active[d.uid];
446
+ } else if (d.kind === "ws-error") {
447
+ ws.readyState = 3;
448
+ clearTimeout(ws._connectTimer);
449
+ var ee = new Event("error");
450
+ ws.onerror && ws.onerror(ee);
451
+ _emit(ws, "error", ee);
452
+ delete active[d.uid];
453
+ }
454
+ };
455
+
456
+ window.WebSocket = NodepodWS;
457
+ })();
458
+ </script>`;
459
+ }
460
+
461
+ // Small "nodepod" badge in the bottom-right corner of preview iframes.
462
+ const WATERMARK_SCRIPT = `<script>
463
+ (function() {
464
+ if (window.__nodepodWatermark) return;
465
+ window.__nodepodWatermark = true;
466
+ document.addEventListener("DOMContentLoaded", function() {
467
+ var a = document.createElement("a");
468
+ a.href = "https://github.com/ScelarOrg/Nodepod";
469
+ a.target = "_blank";
470
+ a.rel = "noopener noreferrer";
471
+ a.textContent = "nodepod";
472
+ a.style.cssText = "position:fixed;bottom:6px;right:8px;z-index:2147483647;"
473
+ + "font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:11px;"
474
+ + "color:rgba(255,255,255,0.45);background:rgba(0,0,0,0.25);padding:2px 6px;"
475
+ + "border-radius:4px;text-decoration:none;pointer-events:auto;transition:color .15s;";
476
+ a.onmouseenter = function() { a.style.color = "rgba(255,255,255,0.85)"; };
477
+ a.onmouseleave = function() { a.style.color = "rgba(255,255,255,0.45)"; };
478
+ document.body.appendChild(a);
479
+ });
480
+ })();
481
+ </script>`;
482
+
483
+ // ── Error page generator ──
484
+
485
+ function errorPage(status, title, message) {
486
+ const html = `<!DOCTYPE html>
487
+ <html lang="en">
488
+ <head>
489
+ <meta charset="utf-8">
490
+ <meta name="viewport" content="width=device-width, initial-scale=1">
491
+ <title>${status} - ${title}</title>
492
+ <style>
493
+ * { margin: 0; padding: 0; box-sizing: border-box; }
494
+ body {
495
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
496
+ background: #0a0a0a; color: #e0e0e0;
497
+ display: flex; align-items: center; justify-content: center;
498
+ min-height: 100vh; padding: 2rem;
499
+ }
500
+ .container { max-width: 480px; text-align: center; }
501
+ .status { font-size: 5rem; font-weight: 700; color: #555; line-height: 1; }
502
+ .title { font-size: 1.25rem; margin-top: 0.75rem; color: #ccc; }
503
+ .message { font-size: 0.875rem; margin-top: 1rem; color: #888; line-height: 1.5; }
504
+ .hint { font-size: 0.8rem; margin-top: 1.5rem; color: #555; }
505
+ </style>
506
+ </head>
507
+ <body>
508
+ <div class="container">
509
+ <div class="status">${status}</div>
510
+ <div class="title">${title}</div>
511
+ <div class="message">${message}</div>
512
+ <div class="hint"></div>
513
+ </div>
514
+ </body>
515
+ </html>`;
516
+ return new Response(html, {
517
+ status,
518
+ statusText: title,
519
+ headers: {
520
+ "content-type": "text/html; charset=utf-8",
521
+ "Cross-Origin-Resource-Policy": "cross-origin",
522
+ "Cross-Origin-Embedder-Policy": "credentialless",
523
+ "Cross-Origin-Opener-Policy": "same-origin",
524
+ },
525
+ });
526
+ }
527
+
528
+ // ── Virtual server proxy ──
529
+
530
+ async function proxyToVirtualServer(
531
+ request,
532
+ serverPort,
533
+ path,
534
+ originalRequest,
535
+ ) {
536
+ if (!port) {
537
+ const clients = await self.clients.matchAll();
538
+ for (const client of clients) {
539
+ client.postMessage({ type: "sw-needs-init" });
540
+ }
541
+ await new Promise((r) => setTimeout(r, 200));
542
+ if (!port) {
543
+ return errorPage(
544
+ 503,
545
+ "Service Unavailable",
546
+ "The Nodepod service worker is still initializing. Please refresh the page.",
547
+ );
548
+ }
549
+ }
550
+
551
+ // Clone the original request before consuming the body, so we can use it
552
+ // for the 404 fallback fetch later if needed.
553
+ const fallbackRequest = originalRequest ? originalRequest.clone() : null;
554
+
555
+ const headers = {};
556
+ request.headers.forEach((v, k) => {
557
+ headers[k] = v;
558
+ });
559
+ headers["host"] = `localhost:${serverPort}`;
560
+
561
+ let body = undefined;
562
+ if (request.method !== "GET" && request.method !== "HEAD") {
563
+ try {
564
+ body = await request.arrayBuffer();
565
+ } catch {
566
+ // body not available
567
+ }
568
+ }
569
+
570
+ const id = nextId++;
571
+ const promise = new Promise((resolve, reject) => {
572
+ pending.set(id, { resolve, reject });
573
+ setTimeout(() => {
574
+ if (pending.has(id)) {
575
+ pending.delete(id);
576
+ reject(new Error("Request timeout: " + path));
577
+ }
578
+ }, 30000);
579
+ });
580
+
581
+ port.postMessage({
582
+ type: "request",
583
+ id,
584
+ data: {
585
+ port: serverPort,
586
+ method: request.method,
587
+ url: path,
588
+ headers,
589
+ body,
590
+ // Pass the full original URL so the main thread can do a fallback
591
+ // network fetch if the virtual server returns 404. This handles
592
+ // cross-origin resources (fonts, CDN assets) that the preview app
593
+ // references but the virtual server doesn't serve.
594
+ originalUrl: request.url,
595
+ },
596
+ });
597
+
598
+ try {
599
+ const data = await promise;
600
+ let responseBody = null;
601
+ if (data.bodyBase64) {
602
+ const binary = atob(data.bodyBase64);
603
+ const bytes = new Uint8Array(binary.length);
604
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
605
+ responseBody = bytes;
606
+ }
607
+ const respHeaders = Object.assign({}, data.headers || {});
608
+
609
+ // Fix MIME type: SPA fallback middleware may serve index.html (text/html)
610
+ // for non-HTML paths. Correct the Content-Type based on file extension.
611
+ const overrideMime = inferMimeType(path, respHeaders);
612
+ if (overrideMime) {
613
+ // Replace Content-Type regardless of casing in original headers
614
+ for (const k of Object.keys(respHeaders)) {
615
+ if (k.toLowerCase() === "content-type") delete respHeaders[k];
616
+ }
617
+ respHeaders["content-type"] = overrideMime;
618
+ }
619
+
620
+ // Inject WebSocket shim + preview script into HTML responses so that
621
+ // browser-side WebSocket connections are routed through nodepod, and
622
+ // user-provided preview scripts run before any page content.
623
+ let finalBody = responseBody;
624
+ const ct = respHeaders["content-type"] || respHeaders["Content-Type"] || "";
625
+ if (ct.includes("text/html") && responseBody) {
626
+ let injection = getWsShimScript();
627
+ if (previewScript) {
628
+ injection += `<script>${previewScript}<` + `/script>`;
629
+ }
630
+ if (watermarkEnabled) {
631
+ injection += WATERMARK_SCRIPT;
632
+ }
633
+ const html = new TextDecoder().decode(responseBody);
634
+ // Inject before <head> or at the start of the document
635
+ const headIdx = html.indexOf("<head");
636
+ if (headIdx >= 0) {
637
+ const closeAngle = html.indexOf(">", headIdx);
638
+ if (closeAngle >= 0) {
639
+ const injected =
640
+ html.slice(0, closeAngle + 1) +
641
+ injection +
642
+ html.slice(closeAngle + 1);
643
+ finalBody = new TextEncoder().encode(injected);
644
+ }
645
+ } else {
646
+ // No <head> tag — prepend the shim
647
+ finalBody = new TextEncoder().encode(injection + html);
648
+ }
649
+ // Update content-length if present
650
+ for (const k of Object.keys(respHeaders)) {
651
+ if (k.toLowerCase() === "content-length") {
652
+ respHeaders[k] = String(finalBody.byteLength);
653
+ }
654
+ }
655
+ }
656
+
657
+ // Ensure COEP compatibility: the parent page sets
658
+ // Cross-Origin-Embedder-Policy: credentialless, so all sub-resources
659
+ // (including iframe content served by this SW) need CORP headers.
660
+ // Additionally, iframe HTML documents need their own COEP/COOP headers
661
+ // so that subresources loaded by the iframe are also allowed.
662
+ if (
663
+ !respHeaders["cross-origin-resource-policy"] &&
664
+ !respHeaders["Cross-Origin-Resource-Policy"]
665
+ ) {
666
+ respHeaders["Cross-Origin-Resource-Policy"] = "cross-origin";
667
+ }
668
+ if (
669
+ !respHeaders["cross-origin-embedder-policy"] &&
670
+ !respHeaders["Cross-Origin-Embedder-Policy"]
671
+ ) {
672
+ respHeaders["Cross-Origin-Embedder-Policy"] = "credentialless";
673
+ }
674
+ if (
675
+ !respHeaders["cross-origin-opener-policy"] &&
676
+ !respHeaders["Cross-Origin-Opener-Policy"]
677
+ ) {
678
+ respHeaders["Cross-Origin-Opener-Policy"] = "same-origin";
679
+ }
680
+
681
+ // If the virtual server returned 404 and we have the original request,
682
+ // fall back to a real network fetch. This handles cases where the preview
683
+ // app generates relative URLs for external resources (e.g. fonts, CDN assets)
684
+ // that the virtual server doesn't serve.
685
+ if (data.statusCode === 404 && fallbackRequest) {
686
+ try {
687
+ return await fetch(fallbackRequest);
688
+ } catch (fetchErr) {
689
+ // Fall through to return the original 404
690
+ }
691
+ }
692
+
693
+ return new Response(finalBody, {
694
+ status: data.statusCode || 200,
695
+ statusText: data.statusMessage || "OK",
696
+ headers: respHeaders,
697
+ });
698
+ } catch (err) {
699
+ const msg = err.message || "Proxy error";
700
+ // If the error is a timeout, it likely means no server is listening
701
+ if (msg.includes("timeout")) {
702
+ return errorPage(
703
+ 504,
704
+ "Gateway Timeout",
705
+ "No server responded on port " +
706
+ serverPort +
707
+ ". Make sure your dev server is running.",
708
+ );
709
+ }
710
+ return errorPage(502, "Bad Gateway", msg);
711
+ }
712
+ }