@plannotator/pi-extension 0.27.5 → 0.27.7

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,555 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/live-proxy-core.ts
2
+ /**
3
+ * Runtime-agnostic core of the live local app annotation proxy.
4
+ *
5
+ * Every DECISION the proxy makes lives here as a pure function, so the Bun
6
+ * transport (packages/server/live-proxy.ts) and the Node transport
7
+ * (packages/shared/live-proxy-node.ts, vendored to Pi) share one
8
+ * implementation instead of drifting copies: the streaming HTML injector
9
+ * state machine, the loopback/Host/Origin validation predicates, the
10
+ * CSP / X-Frame-Options rewrite policy, the WebSocket origin gate, the
11
+ * redirect Location rewrite, and the bridge bootstrap assembly.
12
+ *
13
+ * Nothing in this module may import Bun APIs or node:http — Web-platform
14
+ * globals (URL, TextEncoder) and plain data only. Transports stay thin: they
15
+ * move bytes and call these decisions in the documented order (Host
16
+ * validation FIRST, before any URL construction or upstream contact).
17
+ *
18
+ * Security posture (binding is the contract, not a default):
19
+ * - Transports bind 127.0.0.1 UNCONDITIONALLY. Never the shared
20
+ * env-dependent hostname helper, never any other interface. A proxy bound
21
+ * beyond loopback would relay the user's authenticated dev app to the
22
+ * network.
23
+ * - The Host header is validated before touching upstream (blunts DNS
24
+ * rebinding).
25
+ * - App CSP is stripped on HTML and replaced with a frame-ancestors policy
26
+ * listing exactly the editor origins, which simultaneously defeats app
27
+ * anti-framing headers and prevents hostile sites from framing the proxy.
28
+ * - PLANNOTATOR_URL_HOST / buildAdvertisedUrl are never applied to the proxy
29
+ * origin.
30
+ */
31
+
32
+ /** The literal loopback address every transport must bind. */
33
+ export const LIVE_PROXY_LOOPBACK_HOST = "127.0.0.1";
34
+
35
+ /** Reserved path namespace never forwarded upstream. */
36
+ export const LIVE_PROXY_RESERVED_PREFIX = "/__plannotator__/";
37
+ export const LIVE_PROXY_BRIDGE_PATH = "/__plannotator__/bridge.js";
38
+
39
+ /** The exact tag the injector plants into proxied HTML documents. */
40
+ export const LIVE_PROXY_BRIDGE_TAG = `<script src="${LIVE_PROXY_BRIDGE_PATH}"></script>`;
41
+
42
+ /** RFC 7230 hop-by-hop headers, stripped in both directions. */
43
+ export const HOP_BY_HOP_HEADERS: readonly string[] = [
44
+ "connection",
45
+ "keep-alive",
46
+ "proxy-authenticate",
47
+ "proxy-authorization",
48
+ "te",
49
+ "trailer",
50
+ "transfer-encoding",
51
+ "upgrade",
52
+ ];
53
+
54
+ export function isHopByHopHeader(name: string): boolean {
55
+ return HOP_BY_HOP_HEADERS.includes(name.toLowerCase());
56
+ }
57
+
58
+ /** Cap on client->upstream WS messages queued while the upstream socket is
59
+ * still connecting; overflow closes both sides rather than buffering
60
+ * unboundedly. */
61
+ export const LIVE_PROXY_MAX_PENDING_WS_MESSAGES = 200;
62
+
63
+ export interface LiveAppProxyOptions {
64
+ /** Upstream dev server origin, e.g. http://localhost:5173 (http, loopback). */
65
+ targetUrl: string;
66
+ /** Editor origins allowed to frame the proxied app (frame-ancestors). */
67
+ editorOrigins: string[];
68
+ /** Fully composed bridge body (config prelude + bootstrap + bridge). */
69
+ bridgeJs: string;
70
+ }
71
+
72
+ export interface LiveAppProxy {
73
+ port: number;
74
+ origin: string;
75
+ stop(): void;
76
+ }
77
+
78
+ /** Minimal read view over request headers; DOM Headers satisfies it, and the
79
+ * Node transport adapts node:http's plain header object. */
80
+ export interface HeaderReader {
81
+ get(name: string): string | null;
82
+ }
83
+
84
+ /** True for hostnames that name the local loopback: localhost, the IPv6
85
+ * loopback, or a LITERAL IPv4 address in 127.0.0.0/8. A string-prefix test
86
+ * would also match DNS names like 127.0.0.1.evil.example that resolve
87
+ * anywhere, so the 127/8 rung requires exactly four numeric octets. WHATWG
88
+ * URL parsing canonicalizes numeric spellings (127.1, 0177.0.0.1,
89
+ * 2130706433) to dotted-decimal before a hostname reaches this check. */
90
+ export function isLoopbackHostname(hostname: string): boolean {
91
+ const host = hostname.toLowerCase();
92
+ if (host === "localhost" || host === "::1" || host === "[::1]") return true;
93
+ const octets = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
94
+ if (!octets) return false;
95
+ return Number(octets[1]) <= 255 && Number(octets[2]) <= 255 && Number(octets[3]) <= 255;
96
+ }
97
+
98
+ /** True when the Host header names this proxy on loopback. */
99
+ export function isAllowedProxyHost(hostHeader: string | null, port: number): boolean {
100
+ if (!hostHeader) return false;
101
+ return (
102
+ hostHeader === `127.0.0.1:${port}`
103
+ || hostHeader === `localhost:${port}`
104
+ || hostHeader === `[::1]:${port}`
105
+ );
106
+ }
107
+
108
+ /** True when a browser-supplied Origin header names this proxy itself. */
109
+ export function isAllowedProxyOrigin(origin: string, port: number): boolean {
110
+ return (
111
+ origin === `http://127.0.0.1:${port}`
112
+ || origin === `http://localhost:${port}`
113
+ || origin === `http://[::1]:${port}`
114
+ );
115
+ }
116
+
117
+ /**
118
+ * WebSocket upgrade origin gate. Browsers stamp cross-site WS connects with
119
+ * their Origin, and the proxied upstream connection carries no Origin header
120
+ * at all. Piping a hostile page's connect through would launder it into
121
+ * exactly the origin-less shape dev servers trust as a non-browser client
122
+ * (bypassing e.g. Vite's CVE-2025-24010 cross-site WS protection). An
123
+ * Origin, when present, must name this proxy itself; header-less clients
124
+ * (non-browser tools) pass so HMR keeps working.
125
+ */
126
+ export function isAllowedWsUpgradeOrigin(origin: string | null, port: number): boolean {
127
+ return origin === null || isAllowedProxyOrigin(origin, port);
128
+ }
129
+
130
+ /**
131
+ * Sec-Fetch-Site gate on the bridge body. The bridge embeds the per-session
132
+ * token, and a <script src> include is not subject to CORS: a hostile page
133
+ * that guesses the proxy port could otherwise read the token off the config
134
+ * global. Browsers stamp subresource requests with Sec-Fetch-Site; only the
135
+ * proxied page's own same-origin include (and direct navigation) passes.
136
+ * Header-less clients (curl, tests) pass; this is defense in depth on top of
137
+ * the parent's source and origin checks.
138
+ */
139
+ export function isAllowedBridgeFetchSite(fetchSite: string | null): boolean {
140
+ return !fetchSite || fetchSite === "same-origin" || fetchSite === "none";
141
+ }
142
+
143
+ /**
144
+ * Rewrite an absolute redirect Location that names the upstream dev server
145
+ * back to the proxy origin, or return null to pass it through untouched.
146
+ * The match is by loopback hostname plus the upstream's port, not a string
147
+ * prefix, so it covers alternate loopback spellings (target given as
148
+ * localhost:5173, Location saying 127.0.0.1:5173) and never mangles
149
+ * prefix look-alikes (localhost:51730 is a different service). Relative
150
+ * Locations already resolve against the proxy origin and pass through.
151
+ */
152
+ export function rewriteLoopbackLocation(
153
+ location: string,
154
+ target: URL,
155
+ proxyOrigin: string,
156
+ ): string | null {
157
+ if (!/^http:\/\//i.test(location)) return null;
158
+ let locUrl: URL;
159
+ try {
160
+ locUrl = new URL(location);
161
+ } catch {
162
+ return null;
163
+ }
164
+ if (!isLoopbackHostname(locUrl.hostname)) return null;
165
+ if ((locUrl.port || "80") !== (target.port || "80")) return null;
166
+ return proxyOrigin + locUrl.pathname + locUrl.search + locUrl.hash;
167
+ }
168
+
169
+ /** Document-intent requests get Accept-Encoding stripped so HTML arrives
170
+ * decodable for injection; asset requests keep their encoding untouched. */
171
+ export function isDocumentIntentRequest(headers: HeaderReader): boolean {
172
+ const dest = headers.get("sec-fetch-dest");
173
+ if (dest === "document" || dest === "iframe" || dest === "frame") return true;
174
+ const accept = headers.get("accept");
175
+ return !!accept && accept.includes("text/html");
176
+ }
177
+
178
+ /** Media types are case-insensitive (RFC 9110 8.3): a dev server that
179
+ * answers "TEXT/HTML" is serving HTML, and a case-sensitive test would
180
+ * silently skip both the bridge injection and the framing-header rewrites
181
+ * for it. */
182
+ export function isHtmlContentType(contentType: string | null | undefined): boolean {
183
+ return (contentType ?? "").toLowerCase().includes("text/html");
184
+ }
185
+
186
+ /** The frame-ancestors CSP value HTML responses are rewritten to carry. */
187
+ export function buildFrameAncestorsPolicy(editorOrigins: readonly string[]): string {
188
+ return `frame-ancestors ${editorOrigins.join(" ")}`;
189
+ }
190
+
191
+ /** Minimal mutable view over response headers for the framing rewrite. */
192
+ export interface HeaderMutator {
193
+ delete(name: string): void;
194
+ set(name: string, value: string): void;
195
+ }
196
+
197
+ /**
198
+ * Decided posture, applied to HTML responses ONLY: drop any app CSP and
199
+ * replace it with our frame-ancestors policy. Amending an arbitrary CSP
200
+ * correctly for the injected script plus a runtime <style> is unpredictable;
201
+ * dev servers almost never ship CSP, and this is a dev-only loopback proxy.
202
+ * Non-HTML responses keep their CSP, and their X-Frame-Options: the
203
+ * anti-framing strip exists solely so the editor can frame the app document,
204
+ * and the frame-ancestors replacement lands only on HTML, so a non-HTML
205
+ * response must keep whatever framing protection the app shipped with it.
206
+ */
207
+ export function applyHtmlFramingHeaders(headers: HeaderMutator, frameAncestors: string): void {
208
+ headers.delete("x-frame-options");
209
+ headers.delete("content-security-policy");
210
+ headers.delete("content-security-policy-report-only");
211
+ headers.set("content-security-policy", frameAncestors);
212
+ }
213
+
214
+ // --- Streaming HTML injection -----------------------------------------------
215
+ //
216
+ // Injects exactly one script tag per document, in priority order:
217
+ // 1. immediately after the end of the first <head ...> open tag (so
218
+ // streaming SSR gets the bridge before the body streams),
219
+ // 2. before </head> when no head open tag was seen,
220
+ // 3. appended at end of stream when neither appears.
221
+ // Byte-level scan (the markers are ASCII, safe in UTF-8) holding back at most
222
+ // 8 bytes across chunk boundaries; a head open tag split across chunks is
223
+ // handled by a state machine rather than unbounded buffering.
224
+ //
225
+ // The scan is comment-aware. Head markers that appear inside a comment are
226
+ // not head markers: a codegen banner like
227
+ // <!-- generated file; do not edit <head> by hand -->
228
+ // preceding the real document head used to swallow the bridge into a span the
229
+ // browser never executes, so annotation broke with no diagnostic at all. The
230
+ // scanner therefore skips three kinds of ignored span before matching:
231
+ // - comments: <!-- ... --> (also the legacy --!> terminator)
232
+ // - markup declarations
233
+ // and bogus comments: <!...> (DOCTYPE, CDATA-ish <![CDATA[ ... )
234
+ // - processing-instruction-ish bogus comments: <?...>
235
+ // The last two end at the first ">", which is exactly how the HTML parser
236
+ // treats them outside foreign content, so `<![CDATA[<head>]]>` hides the same
237
+ // bytes here as it does in a browser.
238
+ //
239
+ // Honestly out of scope: raw-text element contents are NOT tracked, so a
240
+ // literal "<!--" inside a <script> or <style> string that precedes the head
241
+ // markers is read as a comment open. Reaching that requires script/style
242
+ // content before the document head (invalid in the head-open case, since the
243
+ // scan stops at the first <head), and the degraded outcome is the existing
244
+ // no-marker fallback (injection appended at end of stream) rather than a
245
+ // silent injection into dead bytes. Conditional comments and unterminated
246
+ // comments degrade the same way.
247
+
248
+ const HEAD_OPEN = "<head";
249
+ const HEAD_CLOSE = "</head>";
250
+ const COMMENT_OPEN = "<!--";
251
+ const GT = 0x3e; /* > */
252
+ const LT = 0x3c; /* < */
253
+ const BANG = 0x21; /* ! */
254
+ const QUESTION = 0x3f; /* ? */
255
+ const HYPHEN = 0x2d; /* - */
256
+ /** Longest marker the scan must be able to defer is "</head>" (7 bytes), so
257
+ * holding back 8 keeps every partial marker available for the next chunk. */
258
+ const HOLDBACK = 8;
259
+
260
+ type InjectorState = "searching" | "in-ignored-span" | "in-head-open-tag" | "done";
261
+
262
+ export function createHtmlInjector(injection: string) {
263
+ const encoder = new TextEncoder();
264
+ const injectionBytes = encoder.encode(injection);
265
+ let state: InjectorState = "searching";
266
+ /** While in-ignored-span: "comment" ends at --> / --!>, "gt" ends at >. */
267
+ let ignoredSpanKind: "comment" | "gt" = "comment";
268
+ let carry = new Uint8Array(0);
269
+
270
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
271
+ const out = new Uint8Array(a.length + b.length);
272
+ out.set(a, 0);
273
+ out.set(b, a.length);
274
+ return out;
275
+ }
276
+
277
+ function lowerAt(buf: Uint8Array, index: number): number {
278
+ const byte = buf[index]!;
279
+ return byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte;
280
+ }
281
+
282
+ function matchesAt(buf: Uint8Array, index: number, marker: string): boolean {
283
+ if (index + marker.length > buf.length) return false;
284
+ for (let i = 0; i < marker.length; i++) {
285
+ if (lowerAt(buf, index + i) !== marker.charCodeAt(i)) return false;
286
+ }
287
+ return true;
288
+ }
289
+
290
+ /** True when the byte after "<head" terminates the tag name. */
291
+ function isHeadBoundary(byte: number): boolean {
292
+ return byte === 0x3e /* > */
293
+ || byte === 0x2f /* / */
294
+ || byte === 0x20
295
+ || byte === 0x09
296
+ || byte === 0x0a
297
+ || byte === 0x0c
298
+ || byte === 0x0d;
299
+ }
300
+
301
+ /**
302
+ * Classify an ignored span starting at a '<'. Returns null when this is not
303
+ * one, or when the buffer does not yet hold enough bytes to tell "<!--" from
304
+ * a bogus comment: undecided positions fall through to the holdback and are
305
+ * re-examined against the next chunk.
306
+ */
307
+ function ignoredSpanAt(
308
+ buf: Uint8Array,
309
+ i: number,
310
+ ): { kind: "comment" | "gt"; openLength: number } | null {
311
+ const next = buf[i + 1];
312
+ if (next === undefined) return null; // undecided: defer
313
+ if (next === QUESTION) return { kind: "gt", openLength: 2 };
314
+ if (next !== BANG) return null;
315
+ if (matchesAt(buf, i, COMMENT_OPEN)) return { kind: "comment", openLength: COMMENT_OPEN.length };
316
+ // "<!" that is not yet known to be "<!--" (chunk ends mid-marker): defer.
317
+ if (i + COMMENT_OPEN.length > buf.length) return null;
318
+ return { kind: "gt", openLength: 2 };
319
+ }
320
+
321
+ /**
322
+ * End of the ignored span that is currently open, or -1 when this buffer
323
+ * does not contain it yet. Comments accept the legacy "--!>" terminator
324
+ * alongside "-->", matching the HTML parser's comment end states.
325
+ */
326
+ function ignoredSpanEnd(buf: Uint8Array, from: number): number {
327
+ if (ignoredSpanKind === "gt") {
328
+ const gt = buf.indexOf(GT, from);
329
+ return gt === -1 ? -1 : gt + 1;
330
+ }
331
+ for (let i = from; i + 2 < buf.length; i++) {
332
+ if (buf[i] !== HYPHEN || buf[i + 1] !== HYPHEN) continue;
333
+ if (buf[i + 2] === GT) return i + 3;
334
+ if (buf[i + 2] === BANG && buf[i + 3] === GT) return i + 4;
335
+ }
336
+ return -1;
337
+ }
338
+
339
+ /** Process buffered bytes, returning output and retaining a small carry. */
340
+ function scan(buf: Uint8Array, flush: boolean): Uint8Array[] {
341
+ const out: Uint8Array[] = [];
342
+ let cursor = 0;
343
+
344
+ while (cursor < buf.length && state !== "done") {
345
+ if (state === "in-head-open-tag") {
346
+ const gt = buf.indexOf(GT, cursor);
347
+ if (gt === -1) {
348
+ out.push(buf.subarray(cursor));
349
+ cursor = buf.length;
350
+ break;
351
+ }
352
+ out.push(buf.subarray(cursor, gt + 1));
353
+ out.push(injectionBytes);
354
+ state = "done";
355
+ cursor = gt + 1;
356
+ break;
357
+ }
358
+
359
+ if (state === "in-ignored-span") {
360
+ // Comment / declaration bytes pass through verbatim; only the search
361
+ // for head markers is suspended until the span closes.
362
+ const end = ignoredSpanEnd(buf, cursor);
363
+ if (end === -1) break; // need more bytes: holdback below
364
+ out.push(buf.subarray(cursor, end));
365
+ cursor = end;
366
+ state = "searching";
367
+ continue;
368
+ }
369
+
370
+ // searching: look for the earliest full or partial marker.
371
+ let emitted = false;
372
+ for (let i = cursor; i < buf.length; i++) {
373
+ if (buf[i] !== LT) continue;
374
+ // Comments and declarations hide whatever they contain, head markers
375
+ // included: enter the span before testing for head markers.
376
+ const ignored = ignoredSpanAt(buf, i);
377
+ if (ignored) {
378
+ const openEnd = i + ignored.openLength;
379
+ out.push(buf.subarray(cursor, openEnd));
380
+ cursor = openEnd;
381
+ ignoredSpanKind = ignored.kind;
382
+ state = "in-ignored-span";
383
+ emitted = true;
384
+ break;
385
+ }
386
+ // Full </head> (no head open tag seen): inject before it.
387
+ if (matchesAt(buf, i, HEAD_CLOSE)) {
388
+ out.push(buf.subarray(cursor, i));
389
+ out.push(injectionBytes);
390
+ state = "done";
391
+ cursor = i;
392
+ emitted = true;
393
+ break;
394
+ }
395
+ // <head followed by a boundary char: enter the open tag.
396
+ if (matchesAt(buf, i, HEAD_OPEN) && i + HEAD_OPEN.length < buf.length) {
397
+ if (isHeadBoundary(buf[i + HEAD_OPEN.length]!)) {
398
+ out.push(buf.subarray(cursor, i));
399
+ cursor = i;
400
+ state = "in-head-open-tag";
401
+ emitted = true;
402
+ break;
403
+ }
404
+ continue; // <header> etc.
405
+ }
406
+ // Partial marker at the buffer tail: defer to the holdback below.
407
+ }
408
+ if (!emitted && state === "searching") break;
409
+ }
410
+
411
+ if (state === "done") {
412
+ // Everything after the injection point passes through untouched.
413
+ if (cursor < buf.length) out.push(buf.subarray(cursor));
414
+ carry = new Uint8Array(0);
415
+ return out;
416
+ }
417
+
418
+ if (state === "in-head-open-tag") {
419
+ // scan() loop above consumed the buffer searching for '>'.
420
+ if (cursor < buf.length) {
421
+ out.push(buf.subarray(cursor));
422
+ }
423
+ carry = new Uint8Array(0);
424
+ return out;
425
+ }
426
+
427
+ // searching / in-ignored-span: hold back the trailing bytes that could be
428
+ // a partial marker ("</hea", "<!-", "--"), so the next chunk re-reads them.
429
+ if (flush) {
430
+ // Stream ended with no usable head marker (including inside an
431
+ // unterminated comment): append rather than drop the bridge.
432
+ out.push(buf.subarray(cursor));
433
+ out.push(injectionBytes);
434
+ state = "done";
435
+ carry = new Uint8Array(0);
436
+ return out;
437
+ }
438
+ const keepFrom = Math.max(cursor, buf.length - HOLDBACK);
439
+ out.push(buf.subarray(cursor, keepFrom));
440
+ carry = buf.slice(keepFrom);
441
+ return out;
442
+ }
443
+
444
+ return {
445
+ push(chunk: Uint8Array): Uint8Array[] {
446
+ const buf = carry.length ? concat(carry, chunk) : chunk;
447
+ carry = new Uint8Array(0);
448
+ return scan(buf, false);
449
+ },
450
+ flush(): Uint8Array[] {
451
+ const buf = carry;
452
+ carry = new Uint8Array(0);
453
+ if (state === "done") return buf.length ? [buf] : [];
454
+ if (state === "in-head-open-tag") {
455
+ // Stream ended inside the head open tag: emit what we have plus the
456
+ // injection so the bridge still ships.
457
+ state = "done";
458
+ const encoderOut: Uint8Array[] = [];
459
+ if (buf.length) encoderOut.push(buf);
460
+ encoderOut.push(injectionBytes);
461
+ return encoderOut;
462
+ }
463
+ return scan(buf, true);
464
+ },
465
+ };
466
+ }
467
+
468
+ // --- Bridge bootstrap assembly ----------------------------------------------
469
+
470
+ export interface LiveBridgeSources {
471
+ /** The per-session token the annotate server owns. */
472
+ token: string;
473
+ /** Editor origins, localhost spelling first (matches the advertised URL). */
474
+ editorOrigins: string[];
475
+ /** Annotation CSS installed by the bootstrap. */
476
+ annotationCss: string;
477
+ /** Live-mode bootstrap that installs the CSS and config. */
478
+ bridgeBootstrap: string;
479
+ /** The bridge script itself. */
480
+ bridgeScript: string;
481
+ }
482
+
483
+ /**
484
+ * Compose the proxy-served bridge body: JSON config prelude (the token the
485
+ * annotate server owns, both editor origin forms with the localhost one
486
+ * first to match the advertised URL, and the annotation CSS), then the
487
+ * bootstrap that installs the CSS, then the bridge itself.
488
+ */
489
+ export function composeLiveBridgeJs(sources: LiveBridgeSources): string {
490
+ return (
491
+ "window.__plannotatorLiveConfig = "
492
+ + JSON.stringify({
493
+ live: true,
494
+ token: sources.token,
495
+ editorOrigins: sources.editorOrigins,
496
+ css: sources.annotationCss,
497
+ })
498
+ + ";\n"
499
+ + sources.bridgeBootstrap
500
+ + "\n"
501
+ + sources.bridgeScript
502
+ );
503
+ }
504
+
505
+ /** The editor origins allowed to frame the proxied app, for an annotate
506
+ * server listening on `port`. Localhost first: it matches the advertised
507
+ * session URL. */
508
+ export function buildLiveEditorOrigins(port: number): string[] {
509
+ return [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
510
+ }
511
+
512
+ /**
513
+ * The URL the editor frames for a live session: the proxy under the
514
+ * LOCALHOST spelling, carrying the target URL's own path and query.
515
+ * localhost keeps the framed app same-site with the editor page
516
+ * (buildAdvertisedUrl advertises localhost locally) and shares the dev
517
+ * app's host-only localhost cookies and storage, which a 127.0.0.1
518
+ * spelling would not (and Safari ITP blocks all cookies in cross-site
519
+ * iframes). The proxy itself still BINDS the 127.0.0.1 literal; browsers
520
+ * that resolve localhost to ::1 first fall back to IPv4 on the refused
521
+ * loopback connect. The path matters too: annotating
522
+ * http://localhost:5173/admin must open /admin, not the app root.
523
+ * PLANNOTATOR_URL_HOST is still never applied here.
524
+ */
525
+ export function buildLiveAppUrl(proxyPort: number, targetUrl: string): string {
526
+ let targetPath = "/";
527
+ try {
528
+ const parsedTarget = new URL(targetUrl);
529
+ targetPath = parsedTarget.pathname + parsedTarget.search;
530
+ } catch {
531
+ targetPath = "/";
532
+ }
533
+ return `http://localhost:${proxyPort}${targetPath}`;
534
+ }
535
+
536
+ /**
537
+ * Stable identity for a live app session's annotation draft.
538
+ *
539
+ * A live session's draft has to key off WHICH APP is being annotated, since
540
+ * the session holds no document text of its own. Normalizing through the URL
541
+ * parser first so the same dev server recovers its draft when the target is
542
+ * spelled slightly differently on a later run (a trailing slash, an uppercase
543
+ * host, an explicit :80). Unparseable values fall back to the trimmed string:
544
+ * a target that never reached the URL parser cannot have started a proxy
545
+ * anyway, and a per-target key that is merely raw is still per-target.
546
+ */
547
+ export function liveAppDraftIdentity(targetUrl: string): string {
548
+ try {
549
+ const url = new URL(targetUrl);
550
+ const path = url.pathname.replace(/\/+$/, "");
551
+ return `${url.origin}${path}${url.search}`;
552
+ } catch {
553
+ return targetUrl.trim();
554
+ }
555
+ }