Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions packages/realm-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -629,6 +634,7 @@ const reportHostShellToManager = async () => {
process.env.VIDEO_SIZE_LIMIT_BYTES ??
DEFAULT_VIDEO_SIZE_LIMIT_BYTES,
),
mediaCacheAdapter,
},
{
...(fullIndexOnStartup ? { fullIndexOnStartup: true as const } : {}),
Expand Down
6 changes: 6 additions & 0 deletions packages/realm-server/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1236,6 +1237,7 @@ export async function createRealm({
videoSizeLimitBytes,
transpileCoordinator,
fullIndexOnStartup,
mediaCacheAdapter,
}: {
dir: string;
definitionLookup: DefinitionLookup;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
);
Expand Down
2 changes: 2 additions & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
232 changes: 232 additions & 0 deletions packages/realm-server/tests/media-cache-serving-test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Uint8Array>();
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<string, string[]> = {},
): 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<void> => {
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<string, string> } = {},
permissions: Record<string, string[]> = {},
): Promise<ResponseWithNodeStream> {
return serveMediaCacheEntry({
request: new Request(`${REALM_URL}_screenshot/card-1`, init),
requestContext: requestContext(permissions),
entry,
mediaCacheAdapter: adapter,
dbAdapter,
});
}

async function lastAccessedAt(): Promise<number> {
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}`,
);
});
});
Loading
Loading