@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.
- package/README.md +2 -0
- package/generated/ai/providers/child-io.ts +93 -0
- package/generated/ai/providers/codex-app-server.ts +38 -2
- package/generated/ai/providers/pi-sdk-node.ts +46 -4
- package/generated/ai/providers/pi-sdk.ts +54 -6
- package/generated/annotate-args.ts +25 -3
- package/generated/bridge-script.ts +4590 -0
- package/generated/call-flow-types.ts +18 -5
- package/generated/config.ts +2 -1
- package/generated/jj-core.ts +51 -3
- package/generated/live-probe.ts +133 -0
- package/generated/live-proxy-core.ts +555 -0
- package/generated/live-proxy-node.ts +419 -0
- package/generated/prompts.ts +1 -0
- package/generated/review-core.ts +11 -0
- package/index.ts +86 -15
- package/package.json +6 -1
- package/plannotator-browser.ts +22 -1
- package/plannotator.html +3 -3
- package/review-editor.html +3 -3
- package/server/serverAnnotate-live.test.ts +273 -0
- package/server/serverAnnotate.ts +146 -10
- package/skills/plannotator/SKILL.md +185 -0
- package/skills/plannotator/agents/openai.yaml +9 -0
|
@@ -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
|
+
}
|