Skip to content
Merged
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
4 changes: 4 additions & 0 deletions e2e-tests/directory.s3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ for (const path of ['/dist/', '/docs/']) {
await res.text();

expect(res.status).toBe(200);
// Directory listings change as files are added, so they're cached mutably.
expect(res.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.mutable
);
});
}

Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test('grabs file from fallback server if r2 request fails', async () => {
);

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.success);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.mutable);
expect(originCalled).toBeTruthy();
expect(await res.text()).toStrictEqual(originResponse);
});
Expand Down
148 changes: 144 additions & 4 deletions e2e-tests/file.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { env, createExecutionContext } from 'cloudflare:test';
import { test, beforeAll, expect } from 'vitest';
import { test, beforeAll, afterEach, expect, vi } from 'vitest';
import { populateR2WithDevBucket } from './util';
import worker from '../src/worker';
import type { Env } from '../src/env';
import { CACHE_HEADERS } from '../src/constants/cache';
import latestVersions from '../src/constants/latestVersions.json' assert { type: 'json' };

const mockedEnv: Env = {
...env,
Expand All @@ -16,6 +17,143 @@ beforeAll(async () => {
await populateR2WithDevBucket();
});

afterEach(() => {
vi.restoreAllMocks();
});

test('GET a versioned asset is cached immutably', async () => {
const ctx = createExecutionContext();

const res = await worker.fetch(
new Request('https://localhost/dist/v20.0.0/docs/apilinks.json'),
mockedEnv,
ctx
);

// Consume the body promise
await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.immutable
);
});

test('HEAD a versioned asset is cached immutably', async () => {
const ctx = createExecutionContext();

const res = await worker.fetch(
new Request('https://localhost/dist/v20.0.0/docs/apilinks.json', {
method: 'HEAD',
}),
mockedEnv,
ctx
);

// Consume the body promise
await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.immutable
);
});

test('GET a `SHASUMS256.txt` is not cached immutably', async () => {
// Regenerated in place by the post-promotion re-sha and signing steps.
const ctx = createExecutionContext();

const res = await worker.fetch(
new Request('https://localhost/dist/v20.0.0/SHASUMS256.txt'),
mockedEnv,
ctx
);

// Consume the body promise
await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.mutable);
});

test('GET through a `latest` alias is not cached immutably', async () => {
// The alias is substituted to a concrete version before R2 is hit, so the
// cache policy has to be decided from the *original* url. Seed the
// substituted target rather than committing a fixture, so this doesn't break
// every time the alias is bumped to a new patch release.
const version = latestVersions['latest-v20.x'];
await env.R2_BUCKET.put(`nodejs/release/${version}/docs/apilinks.json`, '{}');

const ctx = createExecutionContext();

const aliased = await worker.fetch(
new Request('https://localhost/dist/latest-v20.x/docs/apilinks.json'),
mockedEnv,
ctx
);

// Consume the body promise
await aliased.text();

expect(aliased.status).toBe(200);
expect(aliased.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.mutable
);

// ...while the same file under its concrete version stays immutable.
const concrete = await worker.fetch(
new Request(`https://localhost/dist/${version}/docs/apilinks.json`),
mockedEnv,
ctx
);

// Consume the body promise
await concrete.text();

expect(concrete.status).toBe(200);
expect(concrete.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.immutable
);
});

test('GET an invalid object name returns 400 with the failure cache policy', async () => {
vi.spyOn(env.R2_BUCKET, 'get').mockImplementation(() => {
// R2 error 10020: object name not valid
throw new Error('10020: The specified key does not exist.');
});

const ctx = createExecutionContext();

const res = await worker.fetch(
new Request('https://localhost/dist/v20.0.0/SHASUMS256.txt'),
mockedEnv,
ctx
);

expect(res.status).toBe(400);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.failure);
});

test('GET an out-of-bounds range returns 416 with the failure cache policy', async () => {
vi.spyOn(env.R2_BUCKET, 'get').mockImplementation(() => {
// R2 error 10039: range not satisfiable
throw new Error('10039: The requested range is not satisfiable.');
});

const ctx = createExecutionContext();

const res = await worker.fetch(
new Request('https://localhost/dist/v20.0.0/SHASUMS256.txt', {
headers: { range: 'bytes=999999-1000000' },
}),
mockedEnv,
ctx
);

expect(res.status).toBe(416);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.failure);
});

test('GET `/dist/index.json` returns 200', async () => {
const ctx = createExecutionContext();

Expand All @@ -29,7 +167,7 @@ test('GET `/dist/index.json` returns 200', async () => {
await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.success);
expect(res.headers.get('cache-control')).toStrictEqual(CACHE_HEADERS.mutable);
});

test('GET `/dist/asd123.json` returns 404', async () => {
Expand Down Expand Up @@ -203,8 +341,10 @@ test('`if-match` header', async () => {
await res.text();

expect(res.status).toBe(304);
// Must match the 200's policy: a 304's headers update the stored response,
// so `no-store` here would evict the entry being revalidated.
expect(res.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.failure
CACHE_HEADERS.mutable
);
}

Expand All @@ -223,7 +363,7 @@ test('`if-match` header', async () => {

expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toStrictEqual(
CACHE_HEADERS.success
CACHE_HEADERS.mutable
);
}
});
Expand Down
4 changes: 3 additions & 1 deletion src/constants/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const CACHE_HEADERS = {
success: 'public, max-age=3600, s-maxage=14400',
immutable: 'public, immutable, max-age=31536000, s-maxage=31536000',
Comment thread
ovflowd marked this conversation as resolved.
// Mirrors the nginx origin's `public, max-age=3600, s-maxage=14400`.
mutable: 'public, max-age=3600, s-maxage=14400',
failure: 'private, no-cache, no-store, max-age=0, must-revalidate',
};
8 changes: 6 additions & 2 deletions src/middleware/originMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { CACHE_HEADERS } from '../constants/cache';
import type { Context } from '../context';
import type { Request } from '../routes/request';
import { isDirectoryPath } from '../utils/path';
import { cacheControlFor } from '../utils/cache';
import { getOriginalUrl } from '../utils/request';
import type { Middleware } from './middleware';

/**
Expand All @@ -19,6 +21,8 @@ export class OriginMiddleware implements Middleware {
message: 'hit',
});

const originPathname = getOriginalUrl(request).pathname;

const res = await fetch(
`${ctx.env.ORIGIN_HOST}${request.urlObj.pathname}`,
{
Expand Down Expand Up @@ -53,9 +57,9 @@ export class OriginMiddleware implements Middleware {
// Don't cache this response on the client if it's a directory listing,
// since our listing response might end up differently from nginx's at
// some point
'cache-control': isDirectoryPath(request.urlObj.pathname)
'cache-control': isDirectoryPath(originPathname)
? CACHE_HEADERS.failure
: CACHE_HEADERS.success,
: cacheControlFor(originPathname, res.status),
'cache-tag': 'release-worker,release-worker:origin',
},
});
Expand Down
43 changes: 31 additions & 12 deletions src/middleware/r2Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import * as Sentry from '@sentry/cloudflare';
import { CACHE_HEADERS } from '../constants/cache';
import docsDirectory from '../constants/docsDirectory.json' assert { type: 'json' };
import type { Context } from '../context';
import type { GetFileResult } from '../providers/provider';
import type { GetFileResult, HeadFileResult } from '../providers/provider';
import { R2Provider } from '../providers/r2Provider';
import responses from '../responses';
import { hasTrailingSlash, isDirectoryPath } from '../utils/path';
import { cacheControlFor } from '../utils/cache';
import type { Middleware } from './middleware';
import latestVersions from '../constants/latestVersions.json' assert { type: 'json' };
import type { Request } from '../routes/request';
import { renderDirectoryListing } from '../utils/directoryListing';
import { parseConditionalHeaders } from '../utils/request';
import { getOriginalUrl, parseConditionalHeaders } from '../utils/request';
import { once } from '../utils/memo';

const getProvider = once((ctx: Context) => new R2Provider({ ctx }));
Expand Down Expand Up @@ -41,7 +42,7 @@ async function handleDirectory(
): Promise<Response> {
if (!hasTrailingSlash(request.urlObj.pathname)) {
// We always want directory listing requests to have a trailing slash
const url = request.unsubstitutedUrl ?? request.urlObj;
const url = getOriginalUrl(request);
return Response.redirect(`${url}/`, 301);
}
Comment thread
flakey5 marked this conversation as resolved.

Expand All @@ -58,22 +59,37 @@ async function handleDirectory(

let responseBody;
if (request.method === 'GET') {
responseBody = renderDirectoryListing(
Comment thread
ovflowd marked this conversation as resolved.
request.unsubstitutedUrl ?? request.urlObj,
result
);
responseBody = renderDirectoryListing(getOriginalUrl(request), result);
}

return new Response(responseBody, {
headers: {
'last-modified': result.lastModified.toUTCString(),
'content-type': 'text/html',
'cache-control': CACHE_HEADERS.success,
// Directory listings change as files are added.
'cache-control': CACHE_HEADERS.mutable,
'cache-tag': 'release-worker,release-worker:directory',
},
});
}

/**
* Provider headers plus the `cache-control` only we can decide, since it
* depends on the original (unsubstituted) request URL.
*/
function responseHeaders(
result: GetFileResult | HeadFileResult,
request: Request
): Record<string, string> {
return {
...result.httpHeaders,
'cache-control': cacheControlFor(
getOriginalUrl(request).pathname,
result.httpStatusCode
),
};
}

function handleFile(
request: Request,
r2Path: string,
Expand Down Expand Up @@ -102,7 +118,7 @@ async function headFile(

return new Response(undefined, {
status: result.httpStatusCode,
headers: result.httpHeaders,
headers: responseHeaders(result, request),
});
}

Expand All @@ -122,10 +138,13 @@ async function getFile(
if (err instanceof Error) {
if (err.message.includes('10020')) {
// Object name not valid, url probably has some weirdness in it
return new Response(undefined, { status: 400 });
return responses.badRequest();
} else if (err.message.includes('10039')) {
// Range not compatible, probably out of bounds
return new Response(undefined, { status: 416 });
return new Response(undefined, {
status: 416,
headers: { 'cache-control': CACHE_HEADERS.failure },
});
}
}

Expand All @@ -138,7 +157,7 @@ async function getFile(

return new Response(result.contents, {
status: result.httpStatusCode,
headers: result.httpHeaders,
headers: responseHeaders(result, request),
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ export interface Provider {
/**
* Headers returned by the http request made by the Provider to its data source.
* Can be be forwarded to the client.
*
* Deliberately has no `cache-control`: only the middleware knows the original
* request URL that the cache policy depends on, so it owns that header.
*/
export type HttpResponseHeaders = {
etag: string;
'accept-ranges': string;
'access-control-allow-origin': string;
'cache-control': string;
'cache-tag': string;
expires: string;
'last-modified': string;
Expand Down
Loading