@antigenic-oss/paint 0.2.9 → 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.
@@ -0,0 +1,886 @@
1
+ // Service Worker Proxy for pAInt
2
+ // Intercepts iframe requests and proxies them to the target localhost server,
3
+ // preserving all <script> tags for full client-rendered content.
4
+
5
+ // SW version — bump this when making breaking changes so the registration
6
+ // code can detect stale SWs and force a clean re-registration.
7
+ const SW_VERSION = 10;
8
+
9
+ // Inspector code cached at install time
10
+ let inspectorCode = '';
11
+
12
+ // Per-client target URL mapping: clientId -> { origin, url }
13
+ const clientTargets = new Map();
14
+
15
+ // Headers to strip from proxied responses
16
+ const STRIP_HEADERS = new Set([
17
+ 'content-encoding',
18
+ 'transfer-encoding',
19
+ 'cross-origin-embedder-policy',
20
+ 'cross-origin-opener-policy',
21
+ 'cross-origin-resource-policy',
22
+ 'content-security-policy',
23
+ 'content-security-policy-report-only',
24
+ 'x-frame-options',
25
+ ]);
26
+
27
+ // HMR patterns to short-circuit (T020)
28
+ const HMR_RE = /\.hot-update\.|webpack-hmr|turbopack-hmr|__turbopack_hmr|__webpack_hmr/;
29
+
30
+ // ── Install ─────────────────────────────────────────────────────────
31
+ self.addEventListener('install', (event) => {
32
+ event.waitUntil(
33
+ fetch('/dev-editor-inspector.js')
34
+ .then((res) => res.text())
35
+ .then((code) => {
36
+ inspectorCode = code;
37
+ })
38
+ .catch((err) => {
39
+ console.warn('[sw-proxy] Failed to cache inspector code:', err);
40
+ })
41
+ .then(() => self.skipWaiting())
42
+ );
43
+ });
44
+
45
+ // ── Message handler — accept SKIP_WAITING and VERSION_CHECK ─────────
46
+ self.addEventListener('message', (event) => {
47
+ if (!event.data) return;
48
+ if (event.data.type === 'SKIP_WAITING') {
49
+ self.skipWaiting();
50
+ }
51
+ if (event.data.type === 'VERSION_CHECK') {
52
+ // Respond via MessageChannel port if available, otherwise via source
53
+ const port = event.ports && event.ports[0];
54
+ const reply = { type: 'SW_VERSION', version: SW_VERSION };
55
+ if (port) {
56
+ port.postMessage(reply);
57
+ } else if (event.source) {
58
+ event.source.postMessage(reply);
59
+ }
60
+ }
61
+ });
62
+
63
+ // ── Activate ────────────────────────────────────────────────────────
64
+ self.addEventListener('activate', (event) => {
65
+ event.waitUntil(self.clients.claim());
66
+ });
67
+
68
+ // ── Client cleanup — remove stale entries when tabs/iframes close ───
69
+ async function cleanupStaleClients() {
70
+ const activeClients = await self.clients.matchAll({ type: 'all' });
71
+ const activeIds = new Set(activeClients.map((c) => c.id));
72
+ for (const clientId of clientTargets.keys()) {
73
+ if (!activeIds.has(clientId)) {
74
+ clientTargets.delete(clientId);
75
+ }
76
+ }
77
+ }
78
+ // Run cleanup periodically on fetch events (lightweight, no timer needed)
79
+ let cleanupCounter = 0;
80
+
81
+ // ── Helper: strip security headers from a response ──────────────────
82
+ function stripHeaders(originalHeaders) {
83
+ const headers = new Headers();
84
+ for (const [key, value] of originalHeaders.entries()) {
85
+ if (!STRIP_HEADERS.has(key.toLowerCase())) {
86
+ headers.set(key, value);
87
+ }
88
+ }
89
+ return headers;
90
+ }
91
+
92
+ // ── Helper: resolve the target origin for a given clientId ──────────
93
+ function getTargetForClient(clientId) {
94
+ return clientTargets.get(clientId) || null;
95
+ }
96
+
97
+ // ── Helper: build navigation blocker script (T011-T016, T021) ───────
98
+ function buildNavigationBlocker(targetPagePath, targetUrl, targetOrigin) {
99
+ const safePagePath = JSON.stringify(targetPagePath);
100
+ const safeTargetUrl = JSON.stringify(targetUrl);
101
+ const safeTargetOrigin = JSON.stringify(targetOrigin);
102
+
103
+ return `<script data-dev-editor-nav-blocker>
104
+ (function(){
105
+ var tP=${safePagePath},tU=${safeTargetUrl},tO=${safeTargetOrigin};
106
+
107
+ // Fix URL so client-side routers see the correct path (T011)
108
+ try {
109
+ var p = new URLSearchParams(window.location.search);
110
+ p.delete('__sw_target');
111
+ var qs = p.toString();
112
+ history.replaceState(history.state, '', tP + (qs ? '?' + qs : ''));
113
+ } catch(e) {}
114
+
115
+ // Trigger ResizeObserver callbacks after page load (T022)
116
+ // Components like recharts ResponsiveContainer rely on ResizeObserver to
117
+ // detect container dimensions. After history.replaceState and hydration,
118
+ // observers may not fire because the layout hasn't changed from their
119
+ // perspective. Dispatching resize events nudges them to re-measure.
120
+ function triggerResize() {
121
+ window.dispatchEvent(new Event('resize'));
122
+ // Also nudge any ResizeObservers by briefly toggling a root-level style
123
+ var root = document.documentElement;
124
+ if (root) {
125
+ root.style.setProperty('--_sw_resize_hack', '1');
126
+ requestAnimationFrame(function() {
127
+ root.style.removeProperty('--_sw_resize_hack');
128
+ });
129
+ }
130
+ }
131
+ // Fire at multiple timings to catch components that mount at different points
132
+ if (document.readyState === 'complete') {
133
+ setTimeout(triggerResize, 50);
134
+ setTimeout(triggerResize, 300);
135
+ } else {
136
+ window.addEventListener('load', function() {
137
+ setTimeout(triggerResize, 50);
138
+ setTimeout(triggerResize, 300);
139
+ setTimeout(triggerResize, 1000);
140
+ });
141
+ }
142
+
143
+ // Block duplicate inspector scripts via src setter (T010)
144
+ // IMPORTANT: Do NOT silently block (return early) — that prevents the
145
+ // script's onload event from firing, which stalls Next.js hydration
146
+ // when the target app queues the inspector in __next_s.
147
+ var scrDesc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
148
+ if (scrDesc && scrDesc.set) {
149
+ Object.defineProperty(HTMLScriptElement.prototype, 'src', {
150
+ get: scrDesc.get,
151
+ set: function(val) {
152
+ if (typeof val === 'string' && val.indexOf('dev-editor-inspector') >= 0) {
153
+ // Redirect to no-op so onload fires without running duplicate inspector
154
+ scrDesc.set.call(this, 'data:text/javascript,//noop');
155
+ return;
156
+ }
157
+ if (typeof val === 'string') {
158
+ var r = proxyResUrl(val);
159
+ if (r) val = r;
160
+ }
161
+ scrDesc.set.call(this, val);
162
+ },
163
+ configurable: true, enumerable: true
164
+ });
165
+ }
166
+
167
+ // Reload loop detection (T021)
168
+ var rk = '_der_sw';
169
+ var rc = parseInt(sessionStorage.getItem(rk) || '0');
170
+ sessionStorage.setItem(rk, String(rc + 1));
171
+ setTimeout(function(){ sessionStorage.removeItem(rk); }, 3000);
172
+ if (rc > 4) { sessionStorage.removeItem(rk); window.stop(); return; }
173
+
174
+ // HMR redirect: rewrite WebSocket URLs to target origin (T012)
175
+ // Instead of mocking, redirect HMR WebSockets to the real target server
176
+ // so Turbopack's module system can initialize and React can hydrate.
177
+ var OWS = window.WebSocket;
178
+ function rewriteWsUrl(s) {
179
+ try {
180
+ var pu = new URL(s);
181
+ var tu = new URL(tU);
182
+ if (pu.hostname === window.location.hostname && pu.port === window.location.port) {
183
+ pu.hostname = tu.hostname;
184
+ pu.port = tu.port;
185
+ return pu.href;
186
+ }
187
+ } catch(e) {}
188
+ return s;
189
+ }
190
+ window.__wsTrace = [];
191
+ window.WebSocket = function(u, pr) {
192
+ var s = String(u);
193
+ var orig = s;
194
+ if (s.indexOf('_next') >= 0 || s.indexOf('hmr') >= 0 || s.indexOf('webpack') >= 0 || s.indexOf('turbopack') >= 0 || s.indexOf('hot-update') >= 0) {
195
+ u = rewriteWsUrl(s);
196
+ }
197
+ console.debug('[sw-ws-trace]', orig, '->', String(u));
198
+ window.__wsTrace.push({from: orig, to: String(u)});
199
+ return pr !== undefined ? new OWS(u, pr) : new OWS(u);
200
+ };
201
+ window.WebSocket.CONNECTING=0; window.WebSocket.OPEN=1; window.WebSocket.CLOSING=2; window.WebSocket.CLOSED=3;
202
+ window.WebSocket.prototype = OWS.prototype;
203
+
204
+ // HMR redirect: rewrite EventSource URLs to target origin (T012)
205
+ var OES = window.EventSource;
206
+ if (OES) {
207
+ window.EventSource = function(u, c) {
208
+ var s = String(u);
209
+ if (s.indexOf('hmr') >= 0 || s.indexOf('hot') >= 0 || s.indexOf('turbopack') >= 0 || s.indexOf('webpack') >= 0 || s.indexOf('_next') >= 0) {
210
+ try {
211
+ var pu = new URL(s, window.location.origin);
212
+ var tu = new URL(tU);
213
+ pu.hostname = tu.hostname;
214
+ pu.port = tu.port;
215
+ u = pu.href;
216
+ } catch(e) {}
217
+ }
218
+ return c ? new OES(u, c) : new OES(u);
219
+ };
220
+ window.EventSource.CONNECTING=0; window.EventSource.OPEN=1; window.EventSource.CLOSED=2;
221
+ window.EventSource.prototype = OES.prototype;
222
+ }
223
+
224
+ // Patch history.pushState/replaceState with a flag so the Navigation API
225
+ // handler can distinguish soft navigations (router.push → pushState) from
226
+ // hard navigations (window.location.href = x). The navigate event fires
227
+ // synchronously during pushState, so the flag is reliable.
228
+ var _isSoftNav = false;
229
+ var oPush = history.pushState;
230
+ var oReplace = history.replaceState;
231
+ history.pushState = function() {
232
+ _isSoftNav = true;
233
+ try { return oPush.apply(this, arguments); }
234
+ finally { _isSoftNav = false; }
235
+ };
236
+ history.replaceState = function() {
237
+ _isSoftNav = true;
238
+ try { return oReplace.apply(this, arguments); }
239
+ finally { _isSoftNav = false; }
240
+ };
241
+
242
+ // Navigation API intercept (T013)
243
+ // Intercept navigations that escape /sw-proxy/ scope and redirect them back.
244
+ // - User-initiated (link clicks): always intercept
245
+ // - Soft navigations (history.pushState from router.push): let through
246
+ // (the fetch patch routes RSC calls through the proxy, pushState just
247
+ // updates the URL bar which is fine since we already replaceState'd)
248
+ // - Hard navigations (window.location.href/assign/replace): intercept
249
+ // (these would navigate the iframe to pAInt's origin → 404)
250
+ if (window.navigation) {
251
+ window.navigation.addEventListener('navigate', function(e) {
252
+ if (e.hashChange) return;
253
+ // Skip soft navigations (pushState/replaceState from client-side router)
254
+ if (_isSoftNav) return;
255
+ try {
256
+ var d = new URL(e.destination.url);
257
+ if (d.pathname.indexOf('/sw-proxy/') === 0) return;
258
+ if (d.origin !== window.location.origin) return;
259
+ if (e.canIntercept) {
260
+ if (e.userInitiated) {
261
+ window.parent.postMessage({type:'PAGE_NAVIGATE', payload:{path:d.pathname}}, window.location.origin);
262
+ }
263
+ e.intercept({
264
+ handler: function() {
265
+ window.location.replace('/sw-proxy' + d.pathname + d.search + (d.search ? '&' : '?') + '__sw_target=' + encodeURIComponent(tU));
266
+ return new Promise(function() {});
267
+ }
268
+ });
269
+ }
270
+ } catch(err) {}
271
+ });
272
+ }
273
+
274
+ // Patch fetch for same-origin and target-origin API calls (T014)
275
+ var oF = window.fetch;
276
+ function rewriteUrl(s) {
277
+ if (typeof s !== 'string') return s;
278
+ if (s.charAt(0) === '/' && s.indexOf('/sw-proxy/') !== 0) {
279
+ return '/sw-proxy' + s;
280
+ }
281
+ if (s.indexOf(tO) === 0) {
282
+ var path = s.substring(tO.length) || '/';
283
+ return '/sw-proxy' + path;
284
+ }
285
+ return s;
286
+ }
287
+ window.fetch = function(i, n) {
288
+ try {
289
+ if (typeof i === 'string') {
290
+ i = rewriteUrl(i);
291
+ } else if (typeof Request !== 'undefined' && i instanceof Request) {
292
+ var u = new URL(i.url);
293
+ if ((u.origin === window.location.origin && u.pathname.indexOf('/sw-proxy/') !== 0) || u.origin === tO) {
294
+ var rp = u.pathname;
295
+ i = new Request('/sw-proxy' + rp + u.search, i);
296
+ }
297
+ }
298
+ } catch(e) {}
299
+ return oF.call(this, i, n);
300
+ };
301
+
302
+ // Patch XMLHttpRequest (T014)
303
+ var oX = XMLHttpRequest.prototype.open;
304
+ XMLHttpRequest.prototype.open = function(m, u) {
305
+ try {
306
+ if (typeof u === 'string') {
307
+ arguments[1] = rewriteUrl(u);
308
+ }
309
+ } catch(e) {}
310
+ return oX.apply(this, arguments);
311
+ };
312
+
313
+ // Resource URL rewriting helper (T015)
314
+ // NOTE: Returns null (no-op) because the SW intercepts all subresource
315
+ // requests from known proxied clients via clientId mapping. Rewriting URLs
316
+ // to /sw-proxy/ caused path mismatches in the Turbopack module system.
317
+ function proxyResUrl(val) {
318
+ return null;
319
+ }
320
+
321
+ // Patch Element.prototype.setAttribute (T015)
322
+ var oSA = Element.prototype.setAttribute;
323
+ Element.prototype.setAttribute = function(name, value) {
324
+ if (typeof value === 'string') {
325
+ var n = name.toLowerCase();
326
+ if (n === 'src' && this.tagName === 'SCRIPT' && value.indexOf('dev-editor-inspector') >= 0) {
327
+ // Redirect to no-op (same as src setter patch — must not block onload)
328
+ return oSA.call(this, name, 'data:text/javascript,//noop');
329
+ }
330
+ if (n === 'src' || n === 'poster' || n === 'data-src' || (n === 'href' && this.tagName !== 'A')) {
331
+ var r = proxyResUrl(value);
332
+ if (r) value = r;
333
+ }
334
+ if (n === 'srcset') {
335
+ var parts = value.split(',');
336
+ var rewritten = [];
337
+ for (var si = 0; si < parts.length; si++) {
338
+ var entry = parts[si].trim();
339
+ var spIdx = entry.indexOf(' ');
340
+ var url = spIdx >= 0 ? entry.substring(0, spIdx) : entry;
341
+ var desc = spIdx >= 0 ? entry.substring(spIdx) : '';
342
+ var ru = proxyResUrl(url);
343
+ rewritten.push((ru || url) + desc);
344
+ }
345
+ value = rewritten.join(', ');
346
+ }
347
+ }
348
+ return oSA.call(this, name, value);
349
+ };
350
+
351
+ // Patch HTMLImageElement.src (T015)
352
+ var imgDesc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
353
+ if (imgDesc && imgDesc.set) {
354
+ Object.defineProperty(HTMLImageElement.prototype, 'src', {
355
+ get: imgDesc.get,
356
+ set: function(val) {
357
+ var r = proxyResUrl(val);
358
+ imgDesc.set.call(this, r || val);
359
+ },
360
+ configurable: true, enumerable: true
361
+ });
362
+ }
363
+
364
+ // Patch HTMLSourceElement.src (T015)
365
+ var srcDesc = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, 'src');
366
+ if (srcDesc && srcDesc.set) {
367
+ Object.defineProperty(HTMLSourceElement.prototype, 'src', {
368
+ get: srcDesc.get,
369
+ set: function(val) {
370
+ var r = proxyResUrl(val);
371
+ srcDesc.set.call(this, r || val);
372
+ },
373
+ configurable: true, enumerable: true
374
+ });
375
+ }
376
+
377
+ // Patch FontFace constructor (T015)
378
+ var OFontFace = window.FontFace;
379
+ if (OFontFace) {
380
+ window.FontFace = function(family, source, descriptors) {
381
+ if (typeof source === 'string') {
382
+ source = source.replace(/url\\(\\s*(['"]?)([^)'"\\s]+)\\1\\s*\\)/g, function(m, q, urlVal) {
383
+ var r = proxyResUrl(urlVal);
384
+ return r ? 'url(' + q + r + q + ')' : m;
385
+ });
386
+ }
387
+ return new OFontFace(family, source, descriptors);
388
+ };
389
+ window.FontFace.prototype = OFontFace.prototype;
390
+ Object.keys(OFontFace).forEach(function(k) {
391
+ try { window.FontFace[k] = OFontFace[k]; } catch(e) {}
392
+ });
393
+ }
394
+
395
+ // Rewrite url() in dynamically-injected <style> elements (T016)
396
+ var _processedStyles = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
397
+ function rewriteStyleUrls(styleEl) {
398
+ if (_processedStyles) {
399
+ if (_processedStyles.has(styleEl)) return;
400
+ _processedStyles.add(styleEl);
401
+ }
402
+ var css = styleEl.textContent;
403
+ if (!css || css.indexOf('url(') < 0) return;
404
+ var newCss = css.replace(/url\\(\\s*(['"]?)([^)'"\\s]+)\\1\\s*\\)/g, function(m, q, urlVal) {
405
+ var r = proxyResUrl(urlVal);
406
+ return r ? 'url(' + q + r + q + ')' : m;
407
+ });
408
+ if (newCss !== css) styleEl.textContent = newCss;
409
+ }
410
+
411
+ function rewriteNodeUrls(el) {
412
+ if (!el || !el.getAttribute) return;
413
+ var tag = el.tagName;
414
+ var attrs = ['src', 'poster', 'data-src'];
415
+ if (tag !== 'A') attrs.push('href');
416
+ for (var ai = 0; ai < attrs.length; ai++) {
417
+ var val = el.getAttribute(attrs[ai]);
418
+ var r = proxyResUrl(val);
419
+ if (r) el.setAttribute(attrs[ai], r);
420
+ }
421
+ if (el.getAttributeNS) {
422
+ var xval = el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
423
+ var xr = proxyResUrl(xval);
424
+ if (xr) el.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', xr);
425
+ }
426
+ var srcset = el.getAttribute('srcset');
427
+ if (srcset) {
428
+ var parts = srcset.split(',');
429
+ var changed = false;
430
+ var rewritten = [];
431
+ for (var si = 0; si < parts.length; si++) {
432
+ var entry = parts[si].trim();
433
+ var spIdx = entry.indexOf(' ');
434
+ var url = spIdx >= 0 ? entry.substring(0, spIdx) : entry;
435
+ var desc = spIdx >= 0 ? entry.substring(spIdx) : '';
436
+ var ru = proxyResUrl(url);
437
+ if (ru) { rewritten.push(ru + desc); changed = true; }
438
+ else rewritten.push(entry);
439
+ }
440
+ if (changed) el.setAttribute('srcset', rewritten.join(', '));
441
+ }
442
+ }
443
+
444
+ // MutationObserver for dynamically-added elements (T016)
445
+ var rObs = new MutationObserver(function(mutations) {
446
+ for (var mi = 0; mi < mutations.length; mi++) {
447
+ var added = mutations[mi].addedNodes;
448
+ for (var ni = 0; ni < added.length; ni++) {
449
+ var node = added[ni];
450
+ if (node.nodeType === 3 && node.parentElement && node.parentElement.tagName === 'STYLE') {
451
+ rewriteStyleUrls(node.parentElement);
452
+ continue;
453
+ }
454
+ if (node.nodeType !== 1) continue;
455
+ if (node.tagName === 'STYLE') rewriteStyleUrls(node);
456
+ rewriteNodeUrls(node);
457
+ if (node.querySelectorAll) {
458
+ var children = node.querySelectorAll('[src],[href],[poster],[data-src],[srcset]');
459
+ for (var ci = 0; ci < children.length; ci++) rewriteNodeUrls(children[ci]);
460
+ var styles = node.querySelectorAll('style');
461
+ for (var sti = 0; sti < styles.length; sti++) rewriteStyleUrls(styles[sti]);
462
+ }
463
+ }
464
+ if (mutations[mi].type === 'attributes') {
465
+ rewriteNodeUrls(mutations[mi].target);
466
+ }
467
+ }
468
+ });
469
+
470
+ // Scan existing <style> elements
471
+ var existingStyles = document.querySelectorAll('head style, style');
472
+ for (var esi = 0; esi < existingStyles.length; esi++) rewriteStyleUrls(existingStyles[esi]);
473
+
474
+ var obsRoot = document.documentElement || document.body;
475
+ if (obsRoot) {
476
+ rObs.observe(obsRoot, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'href', 'poster', 'data-src', 'srcset'] });
477
+ } else {
478
+ document.addEventListener('DOMContentLoaded', function() {
479
+ rObs.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'href', 'poster', 'data-src', 'srcset'] });
480
+ });
481
+ }
482
+
483
+ // Suppress HMR and chunk-loading errors (T012)
484
+ function isProxyNoise(s) {
485
+ return s.indexOf('hmr') >= 0 || s.indexOf('hot-update') >= 0 || s.indexOf('WebSocket') >= 0 || s.indexOf('__webpack') >= 0 || s.indexOf('__turbopack_hmr') >= 0 || s.indexOf('turbopack-hmr') >= 0 || s.indexOf('ChunkLoadError') >= 0 || s.indexOf('Loading chunk') >= 0 || s.indexOf('Loading CSS chunk') >= 0 || s.indexOf('Expected length, "undefined"') >= 0;
486
+ }
487
+ window.addEventListener('error', function(e) {
488
+ if (isProxyNoise(e.message || '')) {
489
+ e.stopImmediatePropagation(); e.preventDefault(); return false;
490
+ }
491
+ });
492
+ window.addEventListener('unhandledrejection', function(e) {
493
+ if (isProxyNoise(e.reason ? String(e.reason) : '')) {
494
+ e.stopImmediatePropagation(); e.preventDefault();
495
+ }
496
+ });
497
+ // Hide Next.js dev error overlay
498
+ var hs=document.createElement('style');
499
+ hs.textContent='nextjs-portal{display:none!important}';
500
+ document.documentElement.appendChild(hs);
501
+ })();
502
+ </script>`;
503
+ }
504
+
505
+ // ── Helper: build lightweight auth-mode nav script ──────────────────
506
+ // Keeps navigations within /sw-proxy/ scope so the SW can intercept them.
507
+ // Does NOT inject inspector, HMR mocking, or any editing functionality.
508
+ function buildAuthNavigationScript(targetUrl) {
509
+ const safeTargetUrl = JSON.stringify(targetUrl);
510
+
511
+ return `<script data-dev-editor-auth-nav>
512
+ (function(){
513
+ var tU=${safeTargetUrl};
514
+
515
+ // Rewrite location assignments to stay within /sw-proxy/ scope
516
+ function toSwProxy(path) {
517
+ if (typeof path !== 'string') return path;
518
+ // Already prefixed
519
+ if (path.indexOf('/sw-proxy/') === 0) return path;
520
+ // Absolute path
521
+ if (path.charAt(0) === '/') {
522
+ return '/sw-proxy' + path + (path.indexOf('?') >= 0 ? '&' : '?') + '__sw_target=' + encodeURIComponent(tU) + '&__sw_auth=1';
523
+ }
524
+ // Full URL on same origin
525
+ try {
526
+ var u = new URL(path, window.location.href);
527
+ if (u.origin === window.location.origin && u.pathname.indexOf('/sw-proxy/') !== 0) {
528
+ return '/sw-proxy' + u.pathname + (u.search ? u.search + '&' : '?') + '__sw_target=' + encodeURIComponent(tU) + '&__sw_auth=1';
529
+ }
530
+ } catch(e) {}
531
+ return path;
532
+ }
533
+
534
+ // Patch window.location.assign and window.location.replace
535
+ var origAssign = window.location.assign.bind(window.location);
536
+ var origReplace = window.location.replace.bind(window.location);
537
+ window.location.assign = function(url) { return origAssign(toSwProxy(url)); };
538
+ window.location.replace = function(url) { return origReplace(toSwProxy(url)); };
539
+
540
+ // Intercept link clicks
541
+ document.addEventListener('click', function(e) {
542
+ var a = e.target.closest ? e.target.closest('a[href]') : null;
543
+ if (!a) return;
544
+ var href = a.getAttribute('href');
545
+ if (!href || href.charAt(0) === '#') return;
546
+ var rewritten = toSwProxy(href);
547
+ if (rewritten !== href) {
548
+ e.preventDefault();
549
+ window.location.href = rewritten;
550
+ }
551
+ }, true);
552
+
553
+ // Intercept form submissions
554
+ document.addEventListener('submit', function(e) {
555
+ var form = e.target;
556
+ var action = form.getAttribute('action');
557
+ if (action) {
558
+ var rewritten = toSwProxy(action);
559
+ if (rewritten !== action) {
560
+ form.setAttribute('action', rewritten);
561
+ }
562
+ }
563
+ }, true);
564
+
565
+ // Navigation API intercept (modern browsers)
566
+ if (window.navigation) {
567
+ window.navigation.addEventListener('navigate', function(e) {
568
+ if (e.hashChange) return;
569
+ try {
570
+ var d = new URL(e.destination.url);
571
+ if (d.pathname.indexOf('/sw-proxy/') === 0) return;
572
+ if (d.origin !== window.location.origin) return;
573
+ if (e.canIntercept) {
574
+ e.intercept({
575
+ handler: function() {
576
+ window.location.href = toSwProxy(d.pathname + d.search);
577
+ return new Promise(function() {});
578
+ }
579
+ });
580
+ }
581
+ } catch(err) {}
582
+ });
583
+ }
584
+ })();
585
+ </script>`;
586
+ }
587
+
588
+ // ── Helper: rewrite CSS url() references (T019) ────────────────────
589
+ function rewriteCssUrls(css, targetOrigin) {
590
+ const escapedOrigin = targetOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
591
+ // Rewrite absolute-path url() references
592
+ css = css.replace(
593
+ /url\(\s*(["']?)(\/[^)"'\s]+)\1\s*\)/g,
594
+ (match, quote, originalPath) => {
595
+ if (originalPath.startsWith('/sw-proxy/')) return match;
596
+ return `url(${quote}/sw-proxy${originalPath}${quote})`;
597
+ }
598
+ );
599
+ // Rewrite fully-qualified target-origin url() references
600
+ css = css.replace(
601
+ new RegExp(`url\\(\\s*(["']?)${escapedOrigin}(/[^)"'\\s]+)\\1\\s*\\)`, 'g'),
602
+ (match, quote, pathPart) => {
603
+ return `url(${quote}/sw-proxy${pathPart}${quote})`;
604
+ }
605
+ );
606
+ // Rewrite @import with absolute paths
607
+ css = css.replace(
608
+ /@import\s+(["'])(\/[^"']+)\1/g,
609
+ (match, quote, originalPath) => {
610
+ return `@import ${quote}/sw-proxy${originalPath}${quote}`;
611
+ }
612
+ );
613
+ return css;
614
+ }
615
+
616
+ // ── Fetch ───────────────────────────────────────────────────────────
617
+ self.addEventListener('fetch', (event) => {
618
+ const url = new URL(event.request.url);
619
+
620
+ // Only intercept same-origin requests
621
+ if (url.origin !== self.location.origin) return;
622
+
623
+ const hasSwPrefix = url.pathname.startsWith('/sw-proxy/');
624
+
625
+ // For requests without /sw-proxy/ prefix, only intercept if from a known
626
+ // proxied client. This handles dynamic import() and other resource loads
627
+ // that escape the /sw-proxy/ scope after history.replaceState changes
628
+ // the page URL from /sw-proxy/... to /...
629
+ if (!hasSwPrefix) {
630
+ // Never intercept navigations outside /sw-proxy/ scope — let the browser
631
+ // handle them normally. This ensures the fallback to /api/proxy works
632
+ // when the SW proxy times out.
633
+ if (event.request.mode === 'navigate') return;
634
+
635
+ // Don't proxy pAInt's own files to the target server — serve from origin.
636
+ // The inspector script and other pAInt files live on the editor origin,
637
+ // not on the target. Without this, the SW would route these requests
638
+ // through /api/sw-fetch/ to the target server → 404.
639
+ if (url.pathname === '/dev-editor-inspector.js') return;
640
+
641
+ const clientId = event.clientId;
642
+ if (!clientId || !clientTargets.has(clientId)) return;
643
+
644
+ // Route as subresource through the proxy (including HMR requests
645
+ // which need to reach the target's dev server for Turbopack)
646
+ event.respondWith(handleSubresource(event, url));
647
+ return;
648
+ }
649
+
650
+ // Periodic stale client cleanup (every 50 requests)
651
+ if (++cleanupCounter % 50 === 0) cleanupStaleClients();
652
+
653
+ // For navigation requests, extract and store the target URL
654
+ if (event.request.mode === 'navigate') {
655
+ const swTarget = url.searchParams.get('__sw_target');
656
+ if (swTarget) {
657
+ try {
658
+ const parsed = new URL(swTarget);
659
+ const mapping = { origin: parsed.origin, url: swTarget };
660
+ if (event.clientId) clientTargets.set(event.clientId, mapping);
661
+ if (event.resultingClientId) clientTargets.set(event.resultingClientId, mapping);
662
+ } catch (e) {}
663
+ }
664
+
665
+ event.respondWith(handleNavigation(event, url));
666
+ return;
667
+ }
668
+
669
+ // Subresource requests (T018)
670
+ event.respondWith(handleSubresource(event, url));
671
+ });
672
+
673
+ // ── Navigation request handler (T007-T017) ─────────────────────────
674
+ async function handleNavigation(event, url) {
675
+ // Resolve target
676
+ const clientId = event.resultingClientId || event.clientId;
677
+ const target = getTargetForClient(clientId);
678
+ if (!target) {
679
+ return new Response('No target URL configured', { status: 502 });
680
+ }
681
+
682
+ // Re-fetch inspector code if lost (happens when browser terminates and
683
+ // restarts the SW — global variables reset but install doesn't re-fire)
684
+ if (!inspectorCode) {
685
+ try {
686
+ const inspRes = await fetch('/dev-editor-inspector.js');
687
+ if (inspRes.ok) {
688
+ inspectorCode = await inspRes.text();
689
+ }
690
+ } catch (err) {
691
+ console.warn('[sw-proxy] Failed to re-fetch inspector code:', err);
692
+ }
693
+ }
694
+
695
+ // Build the server-side fetch URL: route through /api/sw-fetch/ to avoid
696
+ // CORS issues (SW runs in browser, can't fetch cross-origin localhost:3000)
697
+ let targetPath = url.pathname.replace(/^\/sw-proxy/, '') || '/';
698
+ const params = new URLSearchParams(url.search);
699
+ params.delete('__sw_target');
700
+ const qs = params.toString();
701
+ const fetchUrl = '/api/sw-fetch' + targetPath + (qs ? '?' + qs : '');
702
+
703
+ try {
704
+ const response = await fetch(fetchUrl, {
705
+ headers: { 'x-sw-target': target.url },
706
+ redirect: 'follow',
707
+ });
708
+
709
+ // If the target server redirected (e.g. / → /login), use the final path
710
+ // so the navigation blocker sets the correct URL for React hydration.
711
+ const finalUrl = response.headers.get('x-sw-final-url');
712
+ if (finalUrl) {
713
+ try {
714
+ targetPath = new URL(finalUrl).pathname || '/';
715
+ } catch (e) {}
716
+ }
717
+
718
+ const contentType = response.headers.get('content-type') || '';
719
+ if (!contentType.includes('text/html')) {
720
+ // Not HTML — return with stripped headers
721
+ return new Response(response.body, {
722
+ status: response.status,
723
+ headers: stripHeaders(response.headers),
724
+ });
725
+ }
726
+
727
+ let html = await response.text();
728
+
729
+ // Auth mode: skip inspector/nav-blocker injection so the user can
730
+ // interact with login forms normally. Cookies still flow through the
731
+ // proxy so the session persists when switching to editor mode.
732
+ const isAuthMode = url.searchParams.has('__sw_auth');
733
+
734
+ // Strip CSP meta tags (T009)
735
+ html = html.replace(
736
+ /<meta\s+http-equiv=["']?Content-Security-Policy["']?[^>]*>/gi,
737
+ ''
738
+ );
739
+
740
+ // Remove existing inspector script tags to prevent duplicates (T010)
741
+ html = html.replace(
742
+ /<script[^>]*src=["'][^"']*dev-editor-inspector[^"']*["'][^>]*><\/script>/gi,
743
+ ''
744
+ );
745
+
746
+ // NOTE: We intentionally do NOT rewrite src/href/action attributes in
747
+ // the HTML. The SW intercepts all subresource requests from known proxied
748
+ // clients via clientId mapping, so the /sw-proxy/ prefix is unnecessary.
749
+ // Rewriting caused path mismatches in the Turbopack module system: chunks
750
+ // registered under /sw-proxy/_next/... but flight data referenced /_next/...
751
+ // paths, preventing React hydration.
752
+
753
+ if (isAuthMode) {
754
+ // Auth mode: inject lightweight nav script to keep navigations
755
+ // within /sw-proxy/ scope (login redirects, form submissions, etc.)
756
+ const authNav = buildAuthNavigationScript(target.url);
757
+ if (/<head>/i.test(html)) {
758
+ html = html.replace(/<head>/i, (match) => match + authNav);
759
+ } else if (/<head\s/i.test(html)) {
760
+ html = html.replace(/<head\s[^>]*>/i, (match) => match + authNav);
761
+ } else {
762
+ html = authNav + html;
763
+ }
764
+ } else {
765
+ // Inject navigation blocker after <head> (T011)
766
+ const targetOrigin = target.origin;
767
+ const navBlocker = buildNavigationBlocker(targetPath, target.url, targetOrigin);
768
+ if (/<head>/i.test(html)) {
769
+ html = html.replace(/<head>/i, (match) => match + navBlocker);
770
+ } else if (/<head\s/i.test(html)) {
771
+ html = html.replace(/<head\s[^>]*>/i, (match) => match + navBlocker);
772
+ } else {
773
+ html = navBlocker + html;
774
+ }
775
+
776
+ // Inject inspector script before </body> (T017)
777
+ // Escape </script> in the inspector code to prevent the HTML parser from
778
+ // prematurely closing the injected <script> tag.
779
+ if (inspectorCode) {
780
+ const safeCode = inspectorCode.replace(/<\/script>/gi, '<\\/script>');
781
+ const inspectorTag = '<script>' + safeCode + '</script>';
782
+ if (/<\/body>/i.test(html)) {
783
+ html = html.replace(/<\/body>/i, () => inspectorTag + '</body>');
784
+ } else {
785
+ html += inspectorTag;
786
+ }
787
+ }
788
+ }
789
+
790
+ // Build response with stripped headers (T008)
791
+ const responseHeaders = stripHeaders(response.headers);
792
+ responseHeaders.set('content-type', 'text/html; charset=utf-8');
793
+ responseHeaders.set('cache-control', 'no-cache, no-store, must-revalidate');
794
+ responseHeaders.delete('content-length');
795
+
796
+ return new Response(html, {
797
+ status: response.status,
798
+ headers: responseHeaders,
799
+ });
800
+ } catch (err) {
801
+ return new Response(`Failed to fetch from target: ${err.message}`, {
802
+ status: 502,
803
+ });
804
+ }
805
+ }
806
+
807
+ // ── Subresource request handler (T018-T019) ─────────────────────────
808
+ async function handleSubresource(event, url) {
809
+ // Resolve target from clientId mapping
810
+ const target = getTargetForClient(event.clientId);
811
+ if (!target) {
812
+ // Try to find any target as fallback (single-tab common case)
813
+ const entries = Array.from(clientTargets.values());
814
+ if (entries.length === 0) {
815
+ return fetch(event.request);
816
+ }
817
+ // Use the most recent entry
818
+ var fallback = entries[entries.length - 1];
819
+ return proxySubresource(event, url, fallback);
820
+ }
821
+
822
+ return proxySubresource(event, url, target);
823
+ }
824
+
825
+ async function proxySubresource(event, url, target) {
826
+ // Route through /api/sw-fetch/ to avoid CORS (same as navigation handler)
827
+ const targetPath = url.pathname.replace(/^\/sw-proxy/, '') || '/';
828
+ const fetchUrl = '/api/sw-fetch' + targetPath + url.search;
829
+
830
+ try {
831
+ // Forward all request headers (except host/origin which must reflect the
832
+ // actual target). This ensures RSC headers (RSC, Next-Router-State-Tree,
833
+ // Next-Router-Prefetch), auth headers, and custom API headers all reach
834
+ // the target server.
835
+ const headers = { 'x-sw-target': target.url };
836
+ const SKIP_HEADERS = new Set(['host', 'origin', 'referer', 'connection', 'upgrade']);
837
+ for (const [key, value] of event.request.headers.entries()) {
838
+ if (!SKIP_HEADERS.has(key.toLowerCase())) {
839
+ headers[key] = value;
840
+ }
841
+ }
842
+
843
+ const init = {
844
+ method: event.request.method,
845
+ headers,
846
+ redirect: 'follow',
847
+ };
848
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
849
+ init.body = event.request.body;
850
+ init.duplex = 'half';
851
+ }
852
+ const response = await fetch(fetchUrl, init);
853
+
854
+ const responseHeaders = stripHeaders(response.headers);
855
+
856
+ // CSS url() rewriting (T019)
857
+ const contentType = response.headers.get('content-type') || '';
858
+ if (contentType.includes('text/css')) {
859
+ let css = await response.text();
860
+ css = rewriteCssUrls(css, target.origin);
861
+ responseHeaders.set('content-type', 'text/css; charset=utf-8');
862
+ responseHeaders.set('cache-control', 'no-cache, no-store, must-revalidate');
863
+ responseHeaders.delete('content-length');
864
+ return new Response(css, {
865
+ status: response.status,
866
+ headers: responseHeaders,
867
+ });
868
+ }
869
+
870
+ // Fonts: add CORS headers
871
+ if (
872
+ contentType.includes('font/') ||
873
+ contentType.includes('application/font') ||
874
+ /\.(woff2?|ttf|eot|otf)(\?|$)/.test(url.pathname)
875
+ ) {
876
+ responseHeaders.set('access-control-allow-origin', '*');
877
+ }
878
+
879
+ return new Response(response.body, {
880
+ status: response.status,
881
+ headers: responseHeaders,
882
+ });
883
+ } catch (err) {
884
+ return new Response(`SW proxy error: ${err.message}`, { status: 502 });
885
+ }
886
+ }