From 5cfbb91153ccbc28c03e24cb8d945ff0f0efc777 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 20 Aug 2026 17:48:49 -0400 Subject: [PATCH 1/2] Add screenshot serving internals: MediaCache streaming, realm-read auth, cache headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_screenshot/{instanceLocalPath}` realm route and the serving core it delegates to. Dispatch is by path prefix inside internalHandle (the router table keys on Accept, which image loads can't match), placed after checkPermission so the route inherits realm-read auth — public realms serve unauthenticated, private realms 401. The serving core streams a resolved ledger entry via nodeStream with Content-Type from the ledger, ETag = the content hash, short max-age + stale-while-revalidate scoped public/private by realm readability, RFC-9110 If-None-Match 304s, and a last-accessed bump that feeds the GC's on-demand age-out lane. Addressing resolvers (declared-name manifests, capture-spec canonicalization) plug into a resolution seam that today yields no entry, so every request serves as an uncaptured miss: 404 with a short max-age. The MediaCacheAdapter is threaded from env into every realm the server mounts. Co-Authored-By: Claude Fable 5 --- packages/realm-server/main.ts | 6 + packages/realm-server/tests/helpers/index.ts | 6 + packages/realm-server/tests/index.ts | 2 + .../tests/media-cache-serving-test.ts | 241 ++++++++++++++++++ .../tests/realm-endpoints/screenshot-test.ts | 108 ++++++++ packages/runtime-common/index.ts | 1 + .../runtime-common/media-cache-serving.ts | 176 +++++++++++++ packages/runtime-common/media-cache.ts | 70 ++++- packages/runtime-common/realm.ts | 86 +++++++ 9 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 packages/realm-server/tests/media-cache-serving-test.ts create mode 100644 packages/realm-server/tests/realm-endpoints/screenshot-test.ts create mode 100644 packages/runtime-common/media-cache-serving.ts diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index a099318af25..0d1c2018b34 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -25,6 +25,7 @@ import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import 'decorator-transforms/globals'; import { createRemotePrerenderer } from './prerender/remote-prerenderer.ts'; import { buildCreatePrerenderAuth } from './prerender/auth.ts'; +import { createMediaCacheAdapterFromEnv } from './media-cache/index.ts'; import { isEnvironmentMode, getEnvironmentSlug, @@ -518,6 +519,10 @@ const reportHostShellToManager = async () => { moduleCacheCoordinator, ); + // One store shared by every realm this server mounts; the `_screenshot/` + // route serves every request as an uncaptured miss when none is configured. + let mediaCacheAdapter = createMediaCacheAdapterFromEnv(); + if (SKIP_MODULES_CACHE_CLEAR_ON_STARTUP) { log.info('Skipping modules cache clear on startup (opted out via env)'); } else { @@ -629,6 +634,7 @@ const reportHostShellToManager = async () => { process.env.VIDEO_SIZE_LIMIT_BYTES ?? DEFAULT_VIDEO_SIZE_LIMIT_BYTES, ), + mediaCacheAdapter, }, { ...(fullIndexOnStartup ? { fullIndexOnStartup: true as const } : {}), diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 71ae91c28a5..8fee7f079aa 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -45,6 +45,7 @@ import { DEFAULT_FILE_SIZE_LIMIT_BYTES, DEFAULT_VIDEO_SIZE_LIMIT_BYTES, type MatrixConfig, + type MediaCacheAdapter, type QueuePublisher, type QueueRunner, type Prerenderer, @@ -1236,6 +1237,7 @@ export async function createRealm({ videoSizeLimitBytes, transpileCoordinator, fullIndexOnStartup, + mediaCacheAdapter, }: { dir: string; definitionLookup: DefinitionLookup; @@ -1269,6 +1271,9 @@ export async function createRealm({ // if you are creating a realm to test it directly without a server, you can // also specify `withWorker: true` to also include a worker with your realm withWorker?: true; + // MediaCache object store for the realm's `_screenshot/` route; absent + // means every screenshot request serves as an uncaptured miss. + mediaCacheAdapter?: MediaCacheAdapter; }): Promise<{ realm: Realm; adapter: RealmAdapter }> { await insertPermissions(dbAdapter, new URL(realmURL), permissions); @@ -1345,6 +1350,7 @@ export async function createRealm({ process.env.VIDEO_SIZE_LIMIT_BYTES ?? DEFAULT_VIDEO_SIZE_LIMIT_BYTES, ), transpileCoordinator, + mediaCacheAdapter, }, fullIndexOnStartup ? { fullIndexOnStartup: true as const } : undefined, ); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index af71c108033..4d0e35a0966 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -263,6 +263,7 @@ const ALL_TEST_FILES: string[] = [ './prerender-html-reconcile-test', './media-cache-adapter-test', './media-cache-gc-test', + './media-cache-serving-test', './prerender-server-test', './prerender-manager-test', './prerender-host-shell-recycle-test', @@ -327,6 +328,7 @@ const ALL_TEST_FILES: string[] = [ './realm-endpoints/cancel-indexing-job-test', './realm-endpoints/publishability-test', './realm-endpoints/reindex-test', + './realm-endpoints/screenshot-test', './realm-endpoints/search-test', './realm-endpoints/user-test', './server-endpoints/archive-realm-test', diff --git a/packages/realm-server/tests/media-cache-serving-test.ts b/packages/realm-server/tests/media-cache-serving-test.ts new file mode 100644 index 00000000000..944c32e61d9 --- /dev/null +++ b/packages/realm-server/tests/media-cache-serving-test.ts @@ -0,0 +1,241 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { Readable } from 'node:stream'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { + MediaCacheAdapter, + MediaCacheEntry, + QueuePublisher, + Realm, + RequestContext, + ResponseWithNodeStream, +} from '@cardstack/runtime-common'; +import { + MEDIA_CACHE_MAX_AGE_SECONDS, + MEDIA_CACHE_STALE_WHILE_REVALIDATE_SECONDS, + findMediaCacheEntry, + mediaCacheMissResponse, + putMedia, + serveMediaCacheEntry, +} from '@cardstack/runtime-common'; + +import { nodeStreamToBuffer } from '../stream.ts'; +import { setupDB } from './helpers/index.ts'; + +const REALM_URL = 'http://test-realm/a/'; +const BYTES = new TextEncoder().encode('png-bytes'); + +// Minimal store: real bytes behind the interface, with a switch between the +// two stream shapes the serving layer handles (a node Readable, which +// streams via `nodeStream`, and a bare async iterable, which is buffered). +class FakeMediaCacheAdapter implements MediaCacheAdapter { + objects = new Map(); + streamShape: 'readable' | 'iterable' = 'readable'; + + async put(key: string, bytes: Uint8Array, _opts: { contentType: string }) { + this.objects.set(key, bytes); + } + async head(key: string) { + let bytes = this.objects.get(key); + return bytes ? { size: bytes.length } : undefined; + } + async getStream(key: string) { + let bytes = this.objects.get(key); + if (!bytes) { + return undefined; + } + if (this.streamShape === 'readable') { + return Readable.from(Buffer.from(bytes)); + } + return (async function* () { + yield bytes.slice(0, 3); + yield bytes.slice(3); + })(); + } + async delete(key: string) { + this.objects.delete(key); + } +} + +function requestContext( + permissions: Record = {}, +): RequestContext { + return { + realm: { url: REALM_URL } as unknown as Realm, + permissions, + } as RequestContext; +} + +module(basename(import.meta.filename), function (hooks) { + let dbAdapter: PgAdapter; + let adapter: FakeMediaCacheAdapter; + let entry: MediaCacheEntry; + + setupDB(hooks, { + beforeEach: async ( + _dbAdapter: PgAdapter, + _publisher: QueuePublisher, + ): Promise => { + dbAdapter = _dbAdapter; + adapter = new FakeMediaCacheAdapter(); + await putMedia(dbAdapter, adapter, { + realmURL: REALM_URL, + sourceURL: `${REALM_URL}card-1`, + captureSpecHash: 'spec-1', + sourceGeneration: 1, + bytes: BYTES, + contentType: 'image/png', + lane: 'on-demand', + }); + entry = (await findMediaCacheEntry(dbAdapter, { + realmURL: REALM_URL, + sourceURL: `${REALM_URL}card-1`, + captureSpecHash: 'spec-1', + }))!; + }, + }); + + function serve( + init: { method?: string; headers?: Record } = {}, + permissions: Record = {}, + ): Promise { + return serveMediaCacheEntry({ + request: new Request(`${REALM_URL}_screenshot/card-1`, init), + requestContext: requestContext(permissions), + entry, + mediaCacheAdapter: adapter, + dbAdapter, + }); + } + + async function lastAccessedAt(): Promise { + let row = await findMediaCacheEntry(dbAdapter, { + realmURL: REALM_URL, + sourceURL: `${REALM_URL}card-1`, + captureSpecHash: 'spec-1', + }); + return row!.lastAccessedAt; + } + + test('a hit streams the bytes with content-hash validators', async function (assert) { + let response = await serve(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('content-type'), 'image/png'); + assert.strictEqual( + response.headers.get('content-length'), + String(BYTES.length), + ); + assert.strictEqual( + response.headers.get('etag'), + `"${entry.objectKey}"`, + 'the ETag is the content hash', + ); + assert.strictEqual( + response.headers.get('cache-control'), + `private, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}, stale-while-revalidate=${MEDIA_CACHE_STALE_WHILE_REVALIDATE_SECONDS}`, + ); + assert.ok(response.nodeStream, 'a node Readable rides nodeStream'); + assert.deepEqual( + [...(await nodeStreamToBuffer(response.nodeStream!))], + [...BYTES], + 'the streamed bytes are the stored bytes', + ); + }); + + test('a world-readable realm gets public cache-control', async function (assert) { + let response = await serve({}, { '*': ['read'] }); + assert.ok(response.headers.get('cache-control')!.startsWith('public, ')); + }); + + test('an If-None-Match echo of the ETag answers as a bodyless 304', async function (assert) { + for (let headerValue of [ + `"${entry.objectKey}"`, + `W/"${entry.objectKey}"`, + `"something-else", "${entry.objectKey}"`, + '*', + ]) { + let response = await serve({ + headers: { 'if-none-match': headerValue }, + }); + assert.strictEqual(response.status, 304, `304 for ${headerValue}`); + assert.strictEqual(response.nodeStream, undefined); + assert.strictEqual( + response.headers.get('etag'), + `"${entry.objectKey}"`, + 'the 304 re-states the validator', + ); + } + }); + + test('a stale If-None-Match gets the new bytes', async function (assert) { + let response = await serve({ + headers: { 'if-none-match': '"some-prior-capture-hash"' }, + }); + assert.strictEqual(response.status, 200); + }); + + test('HEAD answers with the hit headers and no body', async function (assert) { + let response = await serve({ method: 'HEAD' }); + assert.strictEqual(response.status, 200); + assert.strictEqual( + response.headers.get('content-length'), + String(BYTES.length), + ); + assert.strictEqual(response.nodeStream, undefined); + assert.strictEqual(await response.text(), ''); + }); + + test('a bare async-iterable stream is buffered into the body', async function (assert) { + adapter.streamShape = 'iterable'; + let response = await serve(); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.nodeStream, undefined); + assert.deepEqual( + [...new Uint8Array(await response.arrayBuffer())], + [...BYTES], + ); + }); + + test('an entry whose object is gone serves as an uncaptured miss', async function (assert) { + await adapter.delete(entry.objectKey); + let response = await serve(); + assert.strictEqual(response.status, 404); + assert.strictEqual( + response.headers.get('cache-control'), + `private, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + 'the miss is briefly cacheable, so image retries are cheap', + ); + }); + + test('200, 304, and HEAD all bump last_accessed_at', async function (assert) { + let before = await lastAccessedAt(); + for (let init of [ + {}, + { headers: { 'if-none-match': `"${entry.objectKey}"` } }, + { method: 'HEAD' }, + ]) { + // ensure the clock can only move forward past the prior stamp + await new Promise((resolve) => setTimeout(resolve, 5)); + await serve(init as any); + let after = await lastAccessedAt(); + assert.true( + after > before, + `serving with ${JSON.stringify(init)} bumped last_accessed_at`, + ); + before = after; + } + }); + + test('the miss response carries realm visibility', async function (assert) { + let response = mediaCacheMissResponse({ + requestContext: requestContext({ '*': ['read'] }), + }); + assert.strictEqual(response.status, 404); + assert.strictEqual( + response.headers.get('cache-control'), + `public, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + ); + }); +}); diff --git a/packages/realm-server/tests/realm-endpoints/screenshot-test.ts b/packages/realm-server/tests/realm-endpoints/screenshot-test.ts new file mode 100644 index 00000000000..8ddb5ecf0ad --- /dev/null +++ b/packages/realm-server/tests/realm-endpoints/screenshot-test.ts @@ -0,0 +1,108 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import type { Test, SuperTest } from 'supertest'; +import { basename } from 'path'; +import type { Realm } from '@cardstack/runtime-common'; +import { MEDIA_CACHE_MAX_AGE_SECONDS } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, createJWT } from '../helpers/index.ts'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; + +// The `_screenshot/` route's HTTP surface: realm-read auth and the +// uncaptured-miss contract (404 with a short, visibility-correct max-age). +// Hit serving — streaming, ETags, 304s — is pinned against the serving core +// directly in media-cache-serving-test.ts; requests here resolve to no +// capture, since nothing in these realms has been captured. +module(`realm-endpoints/${basename(import.meta.filename)}`, function () { + module('GET _screenshot on a private realm', function (hooks) { + let testRealm: Realm; + let request: SuperTest; + + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + permissions: { + mary: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup: (args: { testRealm: Realm; request: SuperTest }) => { + testRealm = args.testRealm; + request = args.request; + }, + }); + + test('an unauthenticated request is a 401, not a 404', async function (assert) { + let response = await request + .get('/_screenshot/some-card') + .set('Accept', 'image/avif,image/webp,image/png,*/*;q=0.8'); + assert.strictEqual(response.status, 401); + }); + + test('a reader without the read grant is refused', async function (assert) { + let response = await request + .get('/_screenshot/some-card') + .set('Accept', 'image/png') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-mary')}`); + assert.strictEqual(response.status, 403); + }); + + test('a reader gets an uncaptured miss with private cache-control', async function (assert) { + let response = await request + .get('/_screenshot/some-card') + .set('Accept', 'image/png') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'mary', ['read'])}`, + ); + assert.strictEqual(response.status, 404); + assert.strictEqual( + response.headers['cache-control'], + `private, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + 'the miss is briefly cacheable and private-realm-scoped', + ); + }); + + test('a declared-name request misses the same way', async function (assert) { + let response = await request + .get('/_screenshot/some-card?name=hero') + .set('Accept', 'image/png') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'mary', ['read'])}`, + ); + assert.strictEqual(response.status, 404); + assert.strictEqual( + response.headers['cache-control'], + `private, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + ); + }); + }); + + module('GET _screenshot on a world-readable realm', function (hooks) { + let request: SuperTest; + + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup: (args: { request: SuperTest }) => { + request = args.request; + }, + }); + + test('an unauthenticated request serves (as a miss) with public cache-control', async function (assert) { + let response = await request + .get('/_screenshot/some-card') + .set('Accept', 'image/avif,image/webp,image/png,*/*;q=0.8'); + assert.strictEqual(response.status, 404, 'a miss, not an auth refusal'); + assert.strictEqual( + response.headers['cache-control'], + `public, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + ); + assert.strictEqual( + response.headers['x-boxel-realm-public-readable'], + 'true', + ); + }); + }); +}); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index c1b8516ff1d..bdd3aeaab8e 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1063,6 +1063,7 @@ export * from './queue.ts'; export * from './job-utils.ts'; export * from './prerender-html-reconcile.ts'; export * from './media-cache.ts'; +export * from './media-cache-serving.ts'; export * from './expression.ts'; export * from './searchable-parity.ts'; export * from './infer-content-type.ts'; diff --git a/packages/runtime-common/media-cache-serving.ts b/packages/runtime-common/media-cache-serving.ts new file mode 100644 index 00000000000..d5608d8209e --- /dev/null +++ b/packages/runtime-common/media-cache-serving.ts @@ -0,0 +1,176 @@ +import type { Readable } from 'stream'; +import { createResponse } from './create-response.ts'; +import type { DBAdapter } from './db.ts'; +import { logger } from './log.ts'; +import { + touchMediaCacheEntry, + type MediaCacheAdapter, + type MediaCacheEntry, +} from './media-cache.ts'; +import { ifNoneMatchMatches, type RequestContext } from './realm.ts'; +import type { ResponseWithNodeStream } from './virtual-network.ts'; + +const log = logger('media-cache'); + +// The HTTP face of a MediaCache capture, shared by every route that serves +// one. The URL is the durable reference — what rendered HTML and +// `meta.screenshots` embed — and the content hash surfaces only as the +// validator: a re-capture changes what the URL serves (the ETag rotates), +// never the URL itself. So the cache policy is a short freshness window +// with cheap revalidation: an unchanged capture revalidates as a bodyless +// 304, a changed one arrives in the same response, and no client holds +// stale bytes for longer than the window. +export const MEDIA_CACHE_MAX_AGE_SECONDS = 60; +export const MEDIA_CACHE_STALE_WHILE_REVALIDATE_SECONDS = 3600; + +// `public` exactly when the realm is world-readable — the same derivation as +// `serveLocalFile` — so a shared cache can hold a public realm's screenshots +// (og:image fetches, crawlers) while a private realm's stay per-client. +export function mediaCacheVisibility( + requestContext: RequestContext, +): 'public' | 'private' { + return requestContext.permissions['*']?.includes('read') + ? 'public' + : 'private'; +} + +function hitCacheControl(requestContext: RequestContext): string { + return ( + `${mediaCacheVisibility(requestContext)}, ` + + `max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}, ` + + `stale-while-revalidate=${MEDIA_CACHE_STALE_WHILE_REVALIDATE_SECONDS}` + ); +} + +// An uncaptured (or no-longer-captured) request. The 404 carries the same +// short freshness window as a hit rather than being uncacheable: an `` +// pointing at a not-yet-captured name picks the image up on a later +// revalidation, and an image load is never made to wait synchronously on +// capture work. +export function mediaCacheMissResponse({ + requestContext, +}: { + requestContext: RequestContext; +}): Response { + return createResponse({ + body: null, + init: { + status: 404, + headers: { + 'cache-control': `${mediaCacheVisibility(requestContext)}, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + }, + }, + requestContext, + }); +} + +function isNodeReadable(stream: AsyncIterable): stream is Readable { + return typeof (stream as Readable).pipe === 'function'; +} + +// Streams one resolved ledger entry. The ETag is the entry's object key — +// the hash of the bytes themselves — so revalidation is exact: any +// `If-None-Match` echo of it answers as a bodyless 304, and a re-capture +// that changed the bytes rotates the validator. Content type comes from the +// ledger (the adapters store no metadata of their own). A hit — 200 or 304 — +// bumps the entry's last-accessed stamp, which is what keeps a capture out +// of the GC's on-demand age-out lane while it is in use. +// +// An entry whose object is gone from the store (reclaimed between this +// request's ledger read and its stream open) is served as an uncaptured +// miss, not an error: the ledger row is the GC's cleanup path and a +// re-capture heals the URL. +export async function serveMediaCacheEntry({ + request, + requestContext, + entry, + mediaCacheAdapter, + dbAdapter, +}: { + request: Request; + requestContext: RequestContext; + entry: MediaCacheEntry; + mediaCacheAdapter: MediaCacheAdapter; + dbAdapter: DBAdapter; +}): Promise { + let etag = `"${entry.objectKey}"`; + let headers = { + 'content-type': entry.contentType, + etag, + 'cache-control': hitCacheControl(requestContext), + }; + + let ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatch && ifNoneMatchMatches(ifNoneMatch, etag)) { + await touch(dbAdapter, entry); + return createResponse({ + body: null, + init: { status: 304, headers }, + requestContext, + }); + } + + if (request.method === 'HEAD') { + await touch(dbAdapter, entry); + return createResponse({ + body: null, + init: { + status: 200, + headers: { ...headers, 'content-length': String(entry.sizeBytes) }, + }, + requestContext, + }); + } + + let stream = await mediaCacheAdapter.getStream(entry.objectKey); + if (!stream) { + return mediaCacheMissResponse({ requestContext }); + } + await touch(dbAdapter, entry); + + let init = { + status: 200, + headers: { ...headers, 'content-length': String(entry.sizeBytes) }, + }; + if (isNodeReadable(stream)) { + // Binary bodies must ride `nodeStream`: the realm-server's Koa bridge + // streams a `nodeStream` verbatim but drains any other body shape + // through text, which corrupts image bytes. Both production adapters + // hand back node Readables, so this is the streaming path. + let response: ResponseWithNodeStream = createResponse({ + body: null, + init, + requestContext, + }); + response.nodeStream = stream; + return response; + } + // A bare async iterable (the interface's minimum) is buffered whole. Safe + // because captures are screenshot-sized, and the entry carries the exact + // size; an adapter serving anything large should return a node Readable. + let chunks: Uint8Array[] = []; + for await (let chunk of stream) { + chunks.push(chunk); + } + let body = new Uint8Array(entry.sizeBytes); + let offset = 0; + for (let chunk of chunks) { + body.set(chunk, offset); + offset += chunk.length; + } + return createResponse({ body, init, requestContext }); +} + +// Best-effort: a failed last-accessed bump must never fail a serve — the +// worst case is an in-use on-demand capture looking idle to the GC one +// sweep early, and the next successful serve re-marks it. +async function touch(dbAdapter: DBAdapter, entry: MediaCacheEntry) { + try { + await touchMediaCacheEntry(dbAdapter, entry); + } catch (e) { + log.warn( + `failed to bump last_accessed_at for media cache entry ${entry.objectKey}:`, + e, + ); + } +} diff --git a/packages/runtime-common/media-cache.ts b/packages/runtime-common/media-cache.ts index cfb9900e528..7b3fa54eab5 100644 --- a/packages/runtime-common/media-cache.ts +++ b/packages/runtime-common/media-cache.ts @@ -69,6 +69,11 @@ export type MediaCacheLane = 'declared' | 'on-demand'; // one canonical spec at one generation. export interface MediaCacheEntryKey { realmURL: string; + // The source instance's canonical URL in its extensionless card-id form + // (matching `boxel_index.file_alias`); the `.json`-suffixed file-URL form + // (matching `boxel_index.url`) also joins correctly, but writers should + // store the id form — it is the shape every other screenshot surface + // (durable URLs, `meta.screenshots`) speaks. sourceURL: string; captureSpecHash: string; sourceGeneration: number; @@ -224,6 +229,65 @@ export async function touchMediaCacheEntry( ] as Expression); } +// Serving-path lookup: the ledger entry for one capture. With a +// `sourceGeneration` the lookup is the exact primary key (the caller's cache +// key pins the generation); without one it is the capture's newest +// generation — the row a re-capture repointed, whatever generation stamped +// it. +export async function findMediaCacheEntry( + dbAdapter: DBAdapter, + { + realmURL, + sourceURL, + captureSpecHash, + sourceGeneration, + }: Omit & { + sourceGeneration?: number; + }, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT * FROM media_cache_ledger WHERE realm_url =`, + param(realmURL), + `AND source_url =`, + param(sourceURL), + `AND capture_spec_hash =`, + param(captureSpecHash), + ...(sourceGeneration != null + ? ([`AND source_generation =`, param(sourceGeneration)] as Expression) + : []), + `ORDER BY source_generation DESC LIMIT 1`, + ] as Expression)) as { + realm_url: string; + source_url: string; + capture_spec_hash: string; + source_generation: number | string; + object_key: string; + source_content_hash: string | null; + lane: MediaCacheLane; + content_type: string; + size_bytes: number | string; + created_at: number | string; + last_accessed_at: number | string; + }[]; + let row = rows[0]; + if (!row) { + return undefined; + } + return { + realmURL: row.realm_url, + sourceURL: row.source_url, + captureSpecHash: row.capture_spec_hash, + sourceGeneration: Number(row.source_generation), + objectKey: row.object_key, + sourceContentHash: row.source_content_hash, + lane: row.lane, + contentType: row.content_type, + sizeBytes: Number(row.size_bytes), + createdAt: Number(row.created_at), + lastAccessedAt: Number(row.last_accessed_at), + }; +} + // --------------------------------------------------------------------------- // GC sweep read side — mirrors `prerender-html-reconcile.ts`: pure queries // plus a pure planning step; the enqueue/delete orchestration lives in the @@ -273,7 +337,8 @@ export async function findMediaCacheGcCandidates( CASE WHEN EXISTS ( SELECT 1 FROM boxel_index i - WHERE i.url = r.source_url AND i.realm_url = r.realm_url + WHERE (i.url = r.source_url OR i.file_alias = r.source_url) + AND i.realm_url = r.realm_url AND i.type = 'instance' AND i.is_deleted IS TRUE ) THEN 'tombstoned' WHEN EXISTS ( @@ -292,7 +357,8 @@ export async function findMediaCacheGcCandidates( `AND ( EXISTS ( SELECT 1 FROM boxel_index i - WHERE i.url = r.source_url AND i.realm_url = r.realm_url + WHERE (i.url = r.source_url OR i.file_alias = r.source_url) + AND i.realm_url = r.realm_url AND i.type = 'instance' AND i.is_deleted IS TRUE ) OR EXISTS ( diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 5883cc1d134..d1028a9bbc4 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -166,6 +166,11 @@ import { import { parseQuery } from './query.ts'; import type { Readable } from 'stream'; import { createResponse } from './create-response.ts'; +import type { MediaCacheAdapter, MediaCacheEntry } from './media-cache.ts'; +import { + mediaCacheMissResponse, + serveMediaCacheEntry, +} from './media-cache-serving.ts'; import { mergeRelationships } from './merge-relationships.ts'; import { getCardDirectoryName } from './helpers/card-directory-name.ts'; import { @@ -959,6 +964,7 @@ export class Realm { #dbAdapter: DBAdapter; #queue: QueuePublisher; #virtualNetwork: VirtualNetwork; + #mediaCacheAdapter: MediaCacheAdapter | undefined; #cachedRealmInfo: RealmInfo | null = null; // md5 of the JSON-stringified `#cachedRealmInfo`. Folded into the // card+json ETag so any path that nulls `#cachedRealmInfo` (e.g. @@ -1035,6 +1041,7 @@ export class Realm { audioSizeLimitBytes, videoSizeLimitBytes, transpileCoordinator, + mediaCacheAdapter, }: { url: string; adapter: RealmAdapter; @@ -1056,6 +1063,10 @@ export class Realm { // in-memory deployments leave this undefined and the uncoordinated // CS-11029 in-process dedup is the only sharing layer. transpileCoordinator?: PopulateCoordinator; + // The MediaCache object store the `_screenshot/` route streams from. + // Optional — a process without one configured serves every screenshot + // request as an uncaptured miss. + mediaCacheAdapter?: MediaCacheAdapter; }, opts?: Options, ) { @@ -1086,6 +1097,7 @@ export class Realm { videoSizeLimitBytes ?? DEFAULT_VIDEO_SIZE_LIMIT_BYTES; this.#disableModuleCaching = Boolean(opts?.disableModuleCaching); this.#copiedFromRealm = opts?.copiedFromRealm; + this.#mediaCacheAdapter = mediaCacheAdapter; let owner: string | undefined; let _fetch = fetcher( virtualNetwork.fetch, @@ -3183,6 +3195,22 @@ export class Realm { message: 'search index is not available', }); } + // Screenshot serving dispatches on the path prefix, not the router + // table: the router keys routes on the Accept header, and the browser + // requests this route must serve (`` loads, og:image fetches) + // send `image/*`-shaped Accept values that match no supported mime + // type. Placed after checkPermission so the route inherits realm-read + // auth exactly like any realm resource. + if ( + (request.method === 'GET' || request.method === 'HEAD') && + localPath.startsWith('_screenshot/') + ) { + return await this.serveScreenshot( + request, + requestContext, + localPath.slice('_screenshot/'.length), + ); + } if (this.#router.handles(request)) { return this.#router.handle(request, requestContext); } else { @@ -3975,6 +4003,64 @@ export class Realm { }); } + // The realm's screenshot-serving surface: `_screenshot/{instanceLocalPath}` + // resolves a capture of one instance and streams it from the MediaCache + // with content-hash ETags and short-max-age revalidation (see + // `media-cache-serving.ts` for the response contract). The durable URL is + // the only public reference — MediaCache hashes surface solely as ETags — + // so a re-capture changes what the URL serves, never the URL itself. + // + // Beyond realm read (enforced by internalHandle before dispatch), the + // parent instance must be live: this gives per-instance ACLs a place to + // land, and captures of a deleted instance stop serving the moment its + // index tombstone appears, ahead of GC reclaiming their artifacts. Any + // request that resolves to no capture — instance missing, store + // unconfigured, addressing unresolvable — is an uncaptured miss: 404 with + // a short max-age so an `` picks up a later capture on revalidation, + // never a synchronous wait inside an image load. + private async serveScreenshot( + request: Request, + requestContext: RequestContext, + instanceLocalPath: string, + ): Promise { + if (!this.#mediaCacheAdapter) { + return mediaCacheMissResponse({ requestContext }); + } + let instanceURL = this.paths.fileURL( + instanceLocalPath.replace(/\.json$/, ''), + ); + let instance = await this.#realmIndexQueryEngine.instance(instanceURL); + if (instance?.type !== 'instance') { + return mediaCacheMissResponse({ requestContext }); + } + let entry = await this.resolveScreenshotEntry( + instanceURL, + new URL(request.url).searchParams, + ); + if (!entry) { + return mediaCacheMissResponse({ requestContext }); + } + return await serveMediaCacheEntry({ + request, + requestContext, + entry, + mediaCacheAdapter: this.#mediaCacheAdapter, + dbAdapter: this.#dbAdapter, + }); + } + + // Resolution seam for the route's addressing forms: `name=` resolves + // through the instance's declared-screenshot manifest, and capture-spec + // params resolve through canonicalization to a ledger entry (see + // `findMediaCacheEntry`). This method is where those resolvers plug in; + // with none available, every request is an uncaptured miss. + private async resolveScreenshotEntry( + _instanceURL: URL, + _searchParams: URLSearchParams, + ): Promise { + return undefined; + } + private async serveLocalFile( request: Request, ref: FileRef, From 55f424a289adc86f312577ed6862df7038c65f04 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 20 Aug 2026 18:33:39 -0400 Subject: [PATCH 2/2] Address review: single nodeStream exit for media bytes, GET-only screenshot dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every media body now leaves through nodeStream — the one shape the Koa bridge streams verbatim (anything else drains through text and corrupts binary). A bare async-iterable adapter stream is wrapped via a node/browser conditional import instead of being buffered into a Response body the bridge would mangle. The _screenshot dispatch admits GET only: checkPermission exempts HEAD from auth realm-wide, so answering HEAD would give unauthenticated callers an existence/size/content-hash oracle over a private realm's captures once hits exist. No consumer of the route sends HEAD. Co-Authored-By: Claude Fable 5 --- .../tests/media-cache-serving-test.ts | 23 ++----- .../tests/realm-endpoints/screenshot-test.ts | 17 +++++ .../runtime-common/media-cache-serving.ts | 64 +++++-------------- .../media-cache-stream-browser.ts | 8 +++ .../runtime-common/media-cache-stream-node.ts | 16 +++++ packages/runtime-common/package.json | 4 ++ packages/runtime-common/realm.ts | 11 ++-- 7 files changed, 75 insertions(+), 68 deletions(-) create mode 100644 packages/runtime-common/media-cache-stream-browser.ts create mode 100644 packages/runtime-common/media-cache-stream-node.ts diff --git a/packages/realm-server/tests/media-cache-serving-test.ts b/packages/realm-server/tests/media-cache-serving-test.ts index 944c32e61d9..4eb2086c89f 100644 --- a/packages/realm-server/tests/media-cache-serving-test.ts +++ b/packages/realm-server/tests/media-cache-serving-test.ts @@ -176,24 +176,16 @@ module(basename(import.meta.filename), function (hooks) { assert.strictEqual(response.status, 200); }); - test('HEAD answers with the hit headers and no body', async function (assert) { - let response = await serve({ method: 'HEAD' }); - assert.strictEqual(response.status, 200); - assert.strictEqual( - response.headers.get('content-length'), - String(BYTES.length), - ); - assert.strictEqual(response.nodeStream, undefined); - assert.strictEqual(await response.text(), ''); - }); - - test('a bare async-iterable stream is buffered into the body', async function (assert) { + test('a bare async-iterable stream still exits via nodeStream', async function (assert) { + // Any body shape other than nodeStream is drained through text by the + // realm-server's Koa bridge, corrupting binary — so both interface-legal + // stream shapes must leave through nodeStream. adapter.streamShape = 'iterable'; let response = await serve(); assert.strictEqual(response.status, 200); - assert.strictEqual(response.nodeStream, undefined); + assert.ok(response.nodeStream, 'the wrapped iterable rides nodeStream'); assert.deepEqual( - [...new Uint8Array(await response.arrayBuffer())], + [...(await nodeStreamToBuffer(response.nodeStream!))], [...BYTES], ); }); @@ -209,12 +201,11 @@ module(basename(import.meta.filename), function (hooks) { ); }); - test('200, 304, and HEAD all bump last_accessed_at', async function (assert) { + test('200 and 304 both bump last_accessed_at', async function (assert) { let before = await lastAccessedAt(); for (let init of [ {}, { headers: { 'if-none-match': `"${entry.objectKey}"` } }, - { method: 'HEAD' }, ]) { // ensure the clock can only move forward past the prior stamp await new Promise((resolve) => setTimeout(resolve, 5)); diff --git a/packages/realm-server/tests/realm-endpoints/screenshot-test.ts b/packages/realm-server/tests/realm-endpoints/screenshot-test.ts index 8ddb5ecf0ad..9187c09d9b1 100644 --- a/packages/realm-server/tests/realm-endpoints/screenshot-test.ts +++ b/packages/realm-server/tests/realm-endpoints/screenshot-test.ts @@ -60,6 +60,23 @@ module(`realm-endpoints/${basename(import.meta.filename)}`, function () { ); }); + test('HEAD is not admitted to the screenshot route', async function (assert) { + // checkPermission exempts HEAD from auth realm-wide, so if this route + // answered HEAD it would hand unauthenticated callers an existence / + // size / content-hash oracle over a private realm's captures. The + // dispatch is GET-only; a HEAD falls through to the generic handlers + // and never gets the route's briefly-cacheable miss shape. + let response = await request + .head('/_screenshot/some-card') + .set('Accept', 'image/png'); + assert.notStrictEqual(response.status, 200); + assert.notStrictEqual( + response.headers['cache-control'], + `private, max-age=${MEDIA_CACHE_MAX_AGE_SECONDS}`, + 'the screenshot miss response did not answer', + ); + }); + test('a declared-name request misses the same way', async function (assert) { let response = await request .get('/_screenshot/some-card?name=hero') diff --git a/packages/runtime-common/media-cache-serving.ts b/packages/runtime-common/media-cache-serving.ts index d5608d8209e..8c1a7c39d2a 100644 --- a/packages/runtime-common/media-cache-serving.ts +++ b/packages/runtime-common/media-cache-serving.ts @@ -1,4 +1,5 @@ import type { Readable } from 'stream'; +import { toNodeStream } from '#media-cache-stream'; import { createResponse } from './create-response.ts'; import type { DBAdapter } from './db.ts'; import { logger } from './log.ts'; @@ -64,10 +65,6 @@ export function mediaCacheMissResponse({ }); } -function isNodeReadable(stream: AsyncIterable): stream is Readable { - return typeof (stream as Readable).pipe === 'function'; -} - // Streams one resolved ledger entry. The ETag is the entry's object key — // the hash of the bytes themselves — so revalidation is exact: any // `If-None-Match` echo of it answers as a bodyless 304, and a re-capture @@ -110,55 +107,28 @@ export async function serveMediaCacheEntry({ }); } - if (request.method === 'HEAD') { - await touch(dbAdapter, entry); - return createResponse({ - body: null, - init: { - status: 200, - headers: { ...headers, 'content-length': String(entry.sizeBytes) }, - }, - requestContext, - }); - } - let stream = await mediaCacheAdapter.getStream(entry.objectKey); if (!stream) { return mediaCacheMissResponse({ requestContext }); } await touch(dbAdapter, entry); - let init = { - status: 200, - headers: { ...headers, 'content-length': String(entry.sizeBytes) }, - }; - if (isNodeReadable(stream)) { - // Binary bodies must ride `nodeStream`: the realm-server's Koa bridge - // streams a `nodeStream` verbatim but drains any other body shape - // through text, which corrupts image bytes. Both production adapters - // hand back node Readables, so this is the streaming path. - let response: ResponseWithNodeStream = createResponse({ - body: null, - init, - requestContext, - }); - response.nodeStream = stream; - return response; - } - // A bare async iterable (the interface's minimum) is buffered whole. Safe - // because captures are screenshot-sized, and the entry carries the exact - // size; an adapter serving anything large should return a node Readable. - let chunks: Uint8Array[] = []; - for await (let chunk of stream) { - chunks.push(chunk); - } - let body = new Uint8Array(entry.sizeBytes); - let offset = 0; - for (let chunk of chunks) { - body.set(chunk, offset); - offset += chunk.length; - } - return createResponse({ body, init, requestContext }); + // The bytes MUST leave via `nodeStream` — the realm-server's Koa bridge + // streams a `nodeStream` verbatim but drains every other body shape + // (including a Response constructed over a Uint8Array) through text, + // which corrupts binary. `toNodeStream` passes an adapter's node Readable + // through and wraps a bare async iterable, so both interface-legal stream + // shapes exit through the one safe path. + let response: ResponseWithNodeStream = createResponse({ + body: null, + init: { + status: 200, + headers: { ...headers, 'content-length': String(entry.sizeBytes) }, + }, + requestContext, + }); + response.nodeStream = toNodeStream(stream) as Readable; + return response; } // Best-effort: a failed last-accessed bump must never fail a serve — the diff --git a/packages/runtime-common/media-cache-stream-browser.ts b/packages/runtime-common/media-cache-stream-browser.ts new file mode 100644 index 00000000000..e39594888fc --- /dev/null +++ b/packages/runtime-common/media-cache-stream-browser.ts @@ -0,0 +1,8 @@ +// Browser stand-in for the node stream adapter. A browser-hosted realm never +// configures a MediaCacheAdapter, so media serving is unreachable there; +// throwing keeps that assumption loud instead of silently mis-serving. +export function toNodeStream( + _stream: AsyncIterable, +): AsyncIterable { + throw new Error('media cache streaming requires a node runtime'); +} diff --git a/packages/runtime-common/media-cache-stream-node.ts b/packages/runtime-common/media-cache-stream-node.ts new file mode 100644 index 00000000000..69b418489fc --- /dev/null +++ b/packages/runtime-common/media-cache-stream-node.ts @@ -0,0 +1,16 @@ +import { Readable } from 'node:stream'; + +// Adapts a MediaCacheAdapter stream to the one body shape the realm-server's +// Koa bridge streams verbatim: a node Readable riding +// `ResponseWithNodeStream.nodeStream`. Every other body shape is drained +// through text by the bridge, which corrupts binary — so serving code must +// route ALL media bytes through this, never through a Response body. An +// adapter stream that already is a Readable passes through untouched. +export function toNodeStream( + stream: AsyncIterable, +): AsyncIterable { + if (typeof (stream as Readable).pipe === 'function') { + return stream; + } + return Readable.from(stream); +} diff --git a/packages/runtime-common/package.json b/packages/runtime-common/package.json index ca5086356fb..098e05dc35c 100644 --- a/packages/runtime-common/package.json +++ b/packages/runtime-common/package.json @@ -11,6 +11,10 @@ "#fetch": { "node": "./fetch-node.ts", "browser": "./fetch-browser.ts" + }, + "#media-cache-stream": { + "node": "./media-cache-stream-node.ts", + "browser": "./media-cache-stream-browser.ts" } }, "exports": { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index d1028a9bbc4..a25e8780ae8 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -3200,11 +3200,12 @@ export class Realm { // requests this route must serve (`` loads, og:image fetches) // send `image/*`-shaped Accept values that match no supported mime // type. Placed after checkPermission so the route inherits realm-read - // auth exactly like any realm resource. - if ( - (request.method === 'GET' || request.method === 'HEAD') && - localPath.startsWith('_screenshot/') - ) { + // auth exactly like any realm resource. GET only — checkPermission + // exempts HEAD from auth realm-wide, so admitting HEAD here would + // hand unauthenticated callers an existence/size/content-hash oracle + // over a private realm's captures; no consumer of this route (image + // loads, crawlers) sends HEAD. + if (request.method === 'GET' && localPath.startsWith('_screenshot/')) { return await this.serveScreenshot( request, requestContext,