From f0b95f4a8352007289b42a6cdaea3c37145967c2 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 15:43:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(02-02):=20offscreen=20=E2=80=94=20CREATE/R?= =?UTF-8?q?EVOKE=20Blob=20URL=20handlers=20on=20keepalivePort=20(D-P2-01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onPortMessage gains CREATE_DOWNLOAD_URL + REVOKE_DOWNLOAD_URL branches. - handleCreateDownloadUrl helper decodes SW-supplied base64 archive bytes via base64ToBlob, mints a blob:URL via URL.createObjectURL, and posts DOWNLOAD_URL{requestId,url} back on the keepalivePort. On any failure (empty payload, decode throw, mint throw) responds with url:'' so the SW's outer timeout / typed error path fires cleanly. - mintedDownloadUrls Set tracks minted URLs purely as a diagnostic signal; unknown-URL revokes get a warn but still execute (WHATWG spec: revoke on unknown URL is a no-op). - base64ToBlob added to the existing src/shared/binary import. - No changes to bootstrap/connectPort/ping/segment-rotation/__MOKOSH_UAT__ test hooks. Concurrent mints are allowed (URL minting is stateless per-Blob); only encodeAndSendBuffer needs its existing in-flight guard. Architectural rationale (D-P2-01): SW lacks URL.createObjectURL per DEC-006; offscreen has it. Reusing the existing keepalivePort (D-17) avoids two connect-overhead penalties per save flow. --- src/offscreen/recorder.ts | 107 +++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 0269b0d..f64721f 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -15,7 +15,7 @@ // is preserved as 3 × 10 s = 30 s.) import { OffscreenLogger } from '../shared/logger'; -import { blobToBase64 } from '../shared/binary'; +import { blobToBase64, base64ToBlob } from '../shared/binary'; import type { Message, TransferredVideoSegment } from '../shared/types'; // ─── Plan 01-11: gated test-hook dynamic import ─────────────────────── @@ -611,6 +611,65 @@ function teardownPortTimers(): void { } } +// D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not +// error) on unexpected revoke ops. URL.revokeObjectURL on an unknown URL +// is a no-op per the WHATWG URL spec, so this is purely diagnostic. The +// Set lives at module scope because URL minting is stateless per-Blob +// and an instance-per-mint container would itself be the only consumer +// (the postMessage round-trip happens via requestId, not Set membership). +const mintedDownloadUrls = new Set(); + +/** + * D-P2-01 (P0-6 fix) CREATE_DOWNLOAD_URL handler: decode the SW-supplied + * base64 archive bytes into a Blob, mint a `blob:chrome-extension://...` + * URL via URL.createObjectURL, and post the URL string back to the SW + * via the keepalivePort as a DOWNLOAD_URL message. + * + * Defense-in-depth: on any failure (empty base64, decode throw, mint + * throw, port disconnected mid-flight) the function responds with + * `{type:'DOWNLOAD_URL', requestId, url:''}` and lets the SW's outer + * timeout fire. Mirrors the encodeAndSendBuffer best-effort pattern — + * partial recovery > silent stall. + * + * @param requestId - Per-mint correlation id (echoed in the DOWNLOAD_URL + * response). The SW's pendingDownloadUrlResolvers Map + * is keyed by this id. + * @param dataBase64 - Base64-encoded archive bytes (no `data:` prefix). + * @param mimeType - MIME type to assign to the reconstructed Blob. + * Defaults to 'application/zip' at the call site. + */ +async function handleCreateDownloadUrl( + requestId: string, + dataBase64: string, + mimeType: string, +): Promise { + if (keepalivePort === null) { + logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping'); + return; + } + let url = ''; + try { + const blob = base64ToBlob(dataBase64, mimeType); + if (blob.size === 0) { + logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url'); + } else { + url = URL.createObjectURL(blob); + mintedDownloadUrls.add(url); + logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`); + } + } catch (err) { + logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err); + } + // Always respond, even on empty url — the SW's downloadArchive checks + // `url === ''` and throws a typed `blob-url-mint-failed` error, which + // routes through saveArchive's catch into the RECORDING_ERROR channel. + try { + keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url }); + } catch (err) { + logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err); + } +} + function onPortMessage(message: unknown): void { // Defense-in-depth: explicit shape check before destructuring (T-1-04). if (typeof message !== 'object' || message === null) { @@ -646,6 +705,52 @@ function onPortMessage(message: unknown): void { // and JSON.stringify(blob) === "{}". See src/shared/binary.ts and // tests/offscreen/port-serialization.test.ts for the contract. void encodeAndSendBuffer(requestId); + return; + } + if (type === 'CREATE_DOWNLOAD_URL') { + // D-P2-01 (P0-6 fix): mint a Blob URL on behalf of the SW (which + // lacks URL.createObjectURL per DEC-006). Each mint gets its own + // requestId; concurrent mints are allowed because URL minting is + // stateless per-Blob — unlike encodeAndSendBuffer, which gates + // concurrent calls because they share the segment ring buffer state. + const requestId = (message as { requestId?: unknown }).requestId; + const dataBase64 = (message as { dataBase64?: unknown }).dataBase64; + const mimeType = (message as { mimeType?: unknown }).mimeType; + if (typeof requestId !== 'string' || requestId.length === 0) { + logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping'); + return; + } + if (typeof dataBase64 !== 'string') { + logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping'); + return; + } + const effectiveMime = typeof mimeType === 'string' ? mimeType : 'application/zip'; + // Fire-and-forget IIFE so the listener stays sync-typed; the helper + // owns its own error reporting + DOWNLOAD_URL response post. + void handleCreateDownloadUrl(requestId, dataBase64, effectiveMime); + return; + } + if (type === 'REVOKE_DOWNLOAD_URL') { + // D-P2-01: free the URL once chrome.downloads.onChanged reports the + // download is complete or interrupted. URL.revokeObjectURL on an + // unknown URL is a no-op per WHATWG spec, so the mintedDownloadUrls + // Set is purely a diagnostic signal (unexpected revokes get a warn + // but still execute). + const url = (message as { url?: unknown }).url; + if (typeof url !== 'string' || url.length === 0) { + logger.warn('REVOKE_DOWNLOAD_URL without url — dropping'); + return; + } + if (!mintedDownloadUrls.has(url)) { + logger.warn(`REVOKE_DOWNLOAD_URL for unminted url ${url.substring(0, 30)}... — proceeding anyway (WHATWG no-op)`); + } + try { + URL.revokeObjectURL(url); + mintedDownloadUrls.delete(url); + } catch (err) { + logger.warn('URL.revokeObjectURL threw:', err); + } + return; } // Any unknown port message type is silently dropped (T-1-04 defense-in-depth). }