Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a7fb5a
fix(annotation): keep preview marks on the artifact they were drawn on
linghaoSu Aug 5, 2026
677fa56
test(annotation): update Draw render-mode expectation to the anchor c…
linghaoSu Aug 5, 2026
6e2ba8f
fix(annotation): read mark bounds in frame layout space, not the scal…
linghaoSu Aug 5, 2026
fbf371b
fix(annotation): address review — URL anchor bridge, retryable probe,…
linghaoSu Aug 5, 2026
8345bab
fix(annotation): probe anchors on the active frame; make probe give-u…
linghaoSu Aug 5, 2026
c5765ba
fix(annotation): drop stale anchor replies; keep bridge failures unan…
linghaoSu Aug 5, 2026
f284004
fix(annotation): bridge large streamed HTML; supersede stale probes b…
linghaoSu Aug 5, 2026
cbd438d
fix(annotation): invalidate probes on document load, filter invisible…
linghaoSu Aug 5, 2026
b716c74
fix(annotation): pre-capture sync joins the in-flight probe chain
linghaoSu Aug 5, 2026
11a04bd
fix(annotation): dragged labels drop their stale anchor; suffix joins…
linghaoSu Aug 5, 2026
07ccc96
fix(annotation): freeze anchor writes through capture; suffix-aware I…
linghaoSu Aug 5, 2026
fe12b0e
fix(annotation): capture freeze covers the resize re-anchor and the b…
linghaoSu Aug 5, 2026
6e59b71
ci: retrigger after UI P0 infra flake
linghaoSu Aug 5, 2026
a6260db
fix(annotation): defer the inactive cleanup while a send is in flight
linghaoSu Aug 6, 2026
12b53fc
fix(annotation): queue a trailing pass when a stale probe reply is di…
linghaoSu Aug 6, 2026
eec0415
fix(annotation): sanitize bridge anchor replies; lean bounded probe e…
linghaoSu Aug 6, 2026
c953192
fix(annotation): refuse oversized forged anchor replies before iterating
linghaoSu Aug 6, 2026
e92affd
Merge remote-tracking branch 'upstream/main' into pr6476
linghaoSu Aug 15, 2026
b770034
test(daemon): close keep-alive sockets before ending the raw-range suite
linghaoSu Aug 15, 2026
65521e4
Merge remote-tracking branch 'upstream/main' into pr6476
linghaoSu Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 120 additions & 9 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,54 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:url-selection-bridge-probe') {
window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*');
window.parent.postMessage({ type: 'od:url-selection-bridge-ready', markAnchors: true }, '*');
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
return;
}
// Annotation marks anchor to the element they were drawn on so they
// survive a reflow (#6361). Draw mode needs element boxes on demand
// WITHOUT comment mode's hover/click interception, and the boxes must
// come from the frame the user actually sees — for powered previews
// that is this URL-loaded frame, not the hidden srcDoc twin. Keep the
// reply shape in sync with the srcDoc bridge in
// apps/web/src/runtime/srcdoc.ts (od:mark-anchor-targets).
if (data.type === 'od:mark-anchor-request') {
// An enumeration failure must NOT look like a healthy empty document:
// the host treats any reply as "bridge answered" and clears its retry
// budget, so a masked exception would silently pin marks frame-relative.
// Stay silent on failure — the host's timeout classifies it as
// unanswered and its cooldown/retry semantics engage.
//
// Only VISIBLE elements may anchor a mark. The host picks the smallest
// containing box, so an invisible nested node (visibility:hidden,
// opacity:0, or a collapsed responsive panel) would otherwise win over
// the visible element the user actually marked and drag the annotation
// onto hidden content on reflow. Mirrors the srcDoc bridge's
// elementVisibleForComment gate.
var markTargets = [];
try {
var found = allTargets();
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
Comment thread
linghaoSu marked this conversation as resolved.
Outdated
for (var mi = 0; mi < found.length; mi++) {
var anchorEl = null;
try { anchorEl = document.querySelector(found[mi].selector); } catch (_) { anchorEl = null; }
if (!anchorEl) continue;
var anchorPos = found[mi].position;
if (!anchorPos || anchorPos.width <= 0 || anchorPos.height <= 0) continue;
try {
var anchorCs = window.getComputedStyle(anchorEl);
if (anchorCs.display === 'none' || anchorCs.visibility === 'hidden' || Number(anchorCs.opacity) === 0) continue;
} catch (_) {}
markTargets.push({
elementId: found[mi].elementId,
selector: found[mi].selector,
position: found[mi].position
});
}
} catch (_) {
return;
}
try {
window.parent.postMessage({ type: 'od:mark-anchor-targets', id: data.id, targets: markTargets }, '*');
} catch (_) {}
return;
}
if (data.type === 'od:preview-runtime-state-capture' && data.id) {
Expand Down Expand Up @@ -1066,7 +1113,7 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
var mo = new MutationObserver(schedulePostTargets);
mo.observe(document.documentElement, { subtree: true, childList: true });
ensureStyle();
window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*');
window.parent.postMessage({ type: 'od:url-selection-bridge-ready', markAnchors: true }, '*');
})();
</script>`;

Expand Down Expand Up @@ -1285,6 +1332,22 @@ function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | '
return injectBeforeBodyClose(html, 'data-od-url-snapshot-bridge', URL_PREVIEW_SNAPSHOT_BRIDGE);
}

// Bridge scripts as a plain suffix, for responses that must keep streaming
// from disk (HTML above HTML_PREVIEW_BRIDGE_MAX_BYTES). Browsers move trailing
// scripts into the body, and every bridge is a self-contained IIFE with a
// re-entry guard, so appending after the streamed bytes is equivalent to the
// buffered injectBeforeBodyClose fallback for a document with no </body>.
// Without this, a >2MiB powered artifact got no selection bridge, never
// advertised markAnchors, and Draw kicked it back to the opaque srcDoc
// sandbox that cannot run it (#6361 review).
function urlPreviewBridgeSuffix(requestedBridge: unknown): string {
let suffix = '';
if (wantsUrlPreviewScrollBridge(requestedBridge)) suffix += URL_PREVIEW_SCROLL_BRIDGE;
if (wantsUrlPreviewSelectionBridge(requestedBridge)) suffix += URL_PREVIEW_SELECTION_BRIDGE;
if (wantsUrlPreviewSnapshotBridge(requestedBridge)) suffix += URL_PREVIEW_SNAPSHOT_BRIDGE;
return suffix;
}

function applyUrlPreviewBridgesToHtml(
transformed: string | Buffer,
mime: string,
Expand Down Expand Up @@ -4929,16 +4992,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
// the file's size+mtime, so any agent rewrite changes them and busts the cache
// immediately; `no-cache` means "always revalidate" (never serve stale without
// asking), so a 304 only happens when the bytes are genuinely unchanged.
function setRawRevalidationHeaders(res: Response, meta: { size: number; mtime: number }): string {
function setRawRevalidationHeaders(
res: Response,
meta: { size: number; mtime: number },
// Appended bridge bytes are part of the representation: the validator must
// change when the suffix changes (bridges requested, bridge script edits),
// or a client that cached the pre-suffix body revalidates to a 304 and
// keeps a bridgeless document — Draw then silently falls back to srcDoc.
bodySuffix = '',
): string {
const mtime = Math.floor(meta.mtime);
const etag = `W/"${meta.size.toString(16)}-${mtime.toString(16)}"`;
const suffixTag = bodySuffix.length > 0
? `-${createHash('sha1').update(bodySuffix).digest('hex').slice(0, 8)}`
: '';
const etag = `W/"${meta.size.toString(16)}-${mtime.toString(16)}${suffixTag}"`;
Comment thread
linghaoSu marked this conversation as resolved.
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', new Date(mtime).toUTCString());
res.setHeader('Cache-Control', 'no-cache');
return etag;
}

function rawRequestIsFresh(req: any, etag: string, mtimeMs: number): boolean {
function rawRequestIsFresh(req: any, etag: string, mtimeMs: number, suffixActive = false): boolean {
// If-None-Match is authoritative when present (RFC 9110 §13.1.3): freshness
// is decided solely by whether the ETag matches — do NOT fall through to
// If-Modified-Since. Otherwise a same-second rewrite (ETag changes
Expand All @@ -4949,6 +5023,11 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
if (typeof ifNoneMatch === 'string') {
return ifNoneMatch.split(',').some((tag) => tag.trim() === etag);
}
// With an appended bridge suffix, Last-Modified (source mtime) does not
// identify the representation: a pre-suffix cache holds the same date but
// different bytes. An IMS-only revalidation must therefore MISS so the
// client refetches the bridged body; ETag clients are unaffected.
if (suffixActive) return false;
const ifModifiedSince = req.headers['if-modified-since'];
if (typeof ifModifiedSince === 'string') {
const since = Date.parse(ifModifiedSince);
Expand Down Expand Up @@ -5044,6 +5123,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
beforeSend?: (mime: string) => void,
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string | Promise<Buffer | string>,
revalidate = false,
streamHtmlSuffix = '',
) {
const meta = await resolveProjectFilePath(
PROJECTS_DIR,
Expand All @@ -5064,10 +5144,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
const willSubstitute =
!isStreamed && !!transformFile && /^text\/html(?:;|$)/i.test(meta.mime);

// A suffix applies only to full-body HTML responses (see appendSuffix
// below); it participates in the validator so pre-suffix caches revalidate
// to fresh bytes instead of 304ing a bridgeless body.
const validatorSuffix =
streamHtmlSuffix.length > 0 && /^text\/html(?:;|$)/i.test(meta.mime) ? streamHtmlSuffix : '';
let currentEtag: string | null = null;
if (revalidate && !willSubstitute) {
currentEtag = setRawRevalidationHeaders(res, meta);
if (rawRequestIsFresh(req, currentEtag, meta.mtime)) {
currentEtag = setRawRevalidationHeaders(res, meta, validatorSuffix);
if (rawRequestIsFresh(req, currentEtag, meta.mtime, validatorSuffix.length > 0)) {
return res.status(304).end();
}
}
Expand All @@ -5093,6 +5178,14 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
return res.status(416).end();
}

// Bridge suffix only applies to a full-body HTML response: a Range
// request must return the file's exact bytes (offsets would otherwise
// shift between requests), and non-HTML never gets bridges.
const appendSuffix =
Comment thread
linghaoSu marked this conversation as resolved.
streamHtmlSuffix.length > 0 && !range && /^text\/html(?:;|$)/i.test(meta.mime)
? Buffer.from(streamHtmlSuffix, 'utf8')
: null;

let start;
let end;
let statusCode;
Expand All @@ -5105,7 +5198,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
start = 0;
end = meta.size - 1;
statusCode = 200;
res.setHeader('Content-Length', String(meta.size));
res.setHeader('Content-Length', String(meta.size + (appendSuffix?.byteLength ?? 0)));
}

res.status(statusCode);
Expand All @@ -5117,7 +5210,12 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
res.destroy(streamErr);
}
});
stream.pipe(res);
if (appendSuffix) {
stream.pipe(res, { end: false });
stream.on('end', () => res.end(appendSuffix));
} else {
stream.pipe(res);
}
return;
}

Expand Down Expand Up @@ -5728,6 +5826,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
);
const skipHtmlPreviewBridge =
/^text\/html(?:;|$)/i.test(meta.mime) && meta.size > HTML_PREVIEW_BRIDGE_MAX_BYTES;
// Large HTML streams from disk without the buffered transform, but the
// preview bridges must still ride along or Draw/comment capabilities
// silently vanish above the size cutoff; append them after the streamed
// bytes instead (see urlPreviewBridgeSuffix).
const largeHtmlBridgeSuffix = skipHtmlPreviewBridge
? urlPreviewBridgeSuffix(req.query.odPreviewBridge)
: '';

await sendProjectFile(
req,
Expand Down Expand Up @@ -5791,6 +5896,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
);
},
true, // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets
largeHtmlBridgeSuffix,
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
Expand Down Expand Up @@ -5858,6 +5964,11 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
});
return applyUrlPreviewBridgesToHtml(transformed, file.mime, req.query.odPreviewBridge);
},
false,
// Powered artifacts above the buffering cutoff still need the preview
// bridges (markAnchors gating: no bridge -> Draw falls back to srcDoc,
// which cannot run Worker/SAB/WASM content). Streamed + appended.
skipPoweredTransform ? urlPreviewBridgeSuffix(req.query.odPreviewBridge) : '',
);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;
Expand Down
104 changes: 104 additions & 0 deletions apps/daemon/tests/project-file-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,87 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html).not.toContain('data-od-url-snapshot-bridge');
});

it('appends the preview bridges after streamed large HTML (issue #6361 review)', async () => {
// A powered artifact above HTML_PREVIEW_BRIDGE_MAX_BYTES streams from
// disk, but must still carry the bridges: without markAnchors Draw kicks
// the powered URL iframe back to the opaque srcDoc sandbox that cannot
// run Worker/SAB/WASM content. The bridges arrive as a suffix on a full
// (non-Range) response; Range replies keep exact file bytes.
const res = await fetch(`${rawUrl('large.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('Large Preview');
expect(html).toContain('data-od-url-scroll-bridge');
expect(html).toContain('data-od-url-selection-bridge');
expect(html).toContain('data-od-url-snapshot-bridge');
expect(html).toContain("type: 'od:url-selection-bridge-ready', markAnchors: true");
// The suffix follows the file's own bytes verbatim.
expect(html.indexOf('Large Preview')).toBeLessThan(html.indexOf('data-od-url-scroll-bridge'));
// Content-Length covers file + suffix so the response is not truncated.
expect(Number(res.headers.get('content-length'))).toBe(Buffer.byteLength(html, 'utf8'));
});

it('busts pre-suffix caches: a stale ETag revalidation returns the bridged body, not 304', async () => {
// A browser that cached the large HTML before the bridge suffix existed
// holds the plain size-mtime ETag. Revalidating with it must MISS (the
// suffix participates in the validator) and return the bridged bytes —
// a 304 here would pin a bridgeless document and Draw would silently
// fall back to srcDoc.
const url = `${rawUrl('large.html')}?odPreviewBridge=selection&odPreviewBridge=snapshot`;
const first = await fetch(url);
expect(first.status).toBe(200);
const freshEtag = first.headers.get('etag')!;
expect(freshEtag).toBeTruthy();

// Reconstruct the pre-fix validator (plain size-mtime, no suffix hash):
// strip the suffix segment from the fresh tag.
const preFixEtag = freshEtag.replace(/-[0-9a-f]{8}"$/, '"');
expect(preFixEtag).not.toBe(freshEtag);
const revalidated = await fetch(url, { headers: { 'If-None-Match': preFixEtag } });
expect(revalidated.status).toBe(200);
const html = await revalidated.text();
expect(html).toContain('data-od-url-selection-bridge');

// The fresh (suffix-aware) validator still 304s.
const fresh = await fetch(url, { headers: { 'If-None-Match': freshEtag } });
expect(fresh.status).toBe(304);

// And a bridge-less request keeps the plain validator (no false busting).
const plain = await fetch(rawUrl('large.html'));
expect(plain.headers.get('etag')).toBe(preFixEtag);
});

it('busts pre-suffix caches on If-Modified-Since-only revalidation too', async () => {
// An IMS-only client (no stored ETag) that cached the pre-suffix body
// sends the unchanged source mtime. Last-Modified does not identify the
// suffixed representation, so freshness must MISS and return the bridged
// bytes rather than 304ing a bridgeless document.
const url = `${rawUrl('large.html')}?odPreviewBridge=selection`;
const first = await fetch(url);
expect(first.status).toBe(200);
const lastModified = first.headers.get('last-modified')!;
expect(lastModified).toBeTruthy();

const ims = await fetch(url, { headers: { 'If-Modified-Since': lastModified } });
expect(ims.status).toBe(200);
expect(await ims.text()).toContain('data-od-url-selection-bridge');

// Without a suffix the IMS fast path still works (no false busting).
const plainFirst = await fetch(rawUrl('large.html'));
const plainLm = plainFirst.headers.get('last-modified')!;
const plainIms = await fetch(rawUrl('large.html'), { headers: { 'If-Modified-Since': plainLm } });
expect(plainIms.status).toBe(304);
});

it('appends the preview bridges after streamed large HTML on the powered route too', async () => {
const res = await fetch(`${poweredUrl('large.html')}?odPreviewBridge=selection&odPreviewBridge=snapshot`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('data-od-url-selection-bridge');
expect(html).toContain('data-od-url-snapshot-bridge');
expect(html).toContain("type: 'od:url-selection-bridge-ready', markAnchors: true");
});

it('injects the URL preview scroll bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>');
Expand Down Expand Up @@ -388,6 +469,29 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(html).not.toContain('data-od-url-scroll-bridge');
});

it('serves the Draw mark-anchor protocol from the URL selection bridge (issue #6361)', async () => {
// Draw-mode content anchoring must resolve element boxes from the frame
// the user sees. For powered previews that is the URL-loaded frame, so
// the daemon bridge answers od:mark-anchor-request and advertises the
// capability in its ready message; the host keys the URL-load decision
// (urlAnchorBridge) off that flag.
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=selection`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html).toContain("'od:mark-anchor-request'");
expect(html).toContain("type: 'od:mark-anchor-targets'");
expect(html).toContain("type: 'od:url-selection-bridge-ready', markAnchors: true");
// Invisible elements must not anchor marks: the host picks the smallest
// containing box, so a hidden nested panel would beat the visible element
// the user marked. The bridge filters on computed style before replying
// (mirroring the srcDoc bridge's elementVisibleForComment gate).
const anchorHandler = html.slice(html.indexOf("'od:mark-anchor-request'"), html.indexOf("'od:mark-anchor-targets'"));
expect(anchorHandler).toContain("visibility === 'hidden'");
expect(anchorHandler).toContain("display === 'none'");
expect(anchorHandler).toContain('Number(anchorCs.opacity) === 0');
expect(anchorHandler).toContain('anchorPos.width <= 0');
});

it('injects the URL preview snapshot bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>');
Expand Down
Loading
Loading