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..4eb2086c89f --- /dev/null +++ b/packages/realm-server/tests/media-cache-serving-test.ts @@ -0,0 +1,232 @@ +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('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.ok(response.nodeStream, 'the wrapped iterable rides nodeStream'); + assert.deepEqual( + [...(await nodeStreamToBuffer(response.nodeStream!))], + [...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 and 304 both bump last_accessed_at', async function (assert) { + let before = await lastAccessedAt(); + for (let init of [ + {}, + { headers: { 'if-none-match': `"${entry.objectKey}"` } }, + ]) { + // 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..9187c09d9b1 --- /dev/null +++ b/packages/realm-server/tests/realm-endpoints/screenshot-test.ts @@ -0,0 +1,125 @@ +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('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') + .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..8c1a7c39d2a --- /dev/null +++ b/packages/runtime-common/media-cache-serving.ts @@ -0,0 +1,146 @@ +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'; +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, + }); +} + +// 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, + }); + } + + let stream = await mediaCacheAdapter.getStream(entry.objectKey); + if (!stream) { + return mediaCacheMissResponse({ requestContext }); + } + await touch(dbAdapter, entry); + + // 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 +// 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-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/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/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 5883cc1d134..a25e8780ae8 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,23 @@ 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. 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, + localPath.slice('_screenshot/'.length), + ); + } if (this.#router.handles(request)) { return this.#router.handle(request, requestContext); } else { @@ -3975,6 +4004,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,