From ef27666ffd426f4221a237d571c511ed810501c5 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 20 Oct 2026 14:25:24 -0400 Subject: [PATCH 1/7] Serve the web build's Brotli 11 twins from the Cloudflare Worker The build already writes a Brotli 11 twin of every js, css, svg, wasm and ttf file, about 20% smaller than Cloudflare's edge compression. The Worker now serves the twin with encodeBody: manual, for current assets and previous builds in R2, and Cloudflare decodes it for clients without Brotli. The asset store gives a twin its original's ETag and the edge weakens ETags it re-encodes, so the Worker drops conditional headers from asset requests, gives twins their own ETag and answers If-None-Match itself; otherwise fixed-name files like service-worker.js stopped revalidating with 304. --- web/cloudflare-worker/.assetsignore | 2 - web/cloudflare-worker/src/index.ts | 162 ++++++++++++++---- .../tests/createWorkerHarness.ts | 17 +- web/cloudflare-worker/tests/worker.test.ts | 116 ++++++++++++- 4 files changed, 251 insertions(+), 46 deletions(-) diff --git a/web/cloudflare-worker/.assetsignore b/web/cloudflare-worker/.assetsignore index 0bd29c77a5a9..9685062260e7 100644 --- a/web/cloudflare-worker/.assetsignore +++ b/web/cloudflare-worker/.assetsignore @@ -1,4 +1,2 @@ -# Precompressed twins. Cloudflare compresses Worker responses at the edge. -*.br # Source maps are uploaded to Sentry at build time and must not be publicly served. *.map diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts index 605990253d84..61f0e28ec9ac 100644 --- a/web/cloudflare-worker/src/index.ts +++ b/web/cloudflare-worker/src/index.ts @@ -6,6 +6,18 @@ const HASHED_ASSET_PATH = /^\/[^/]+(?:-[0-9a-f]{16}\.bundle\.js|\.[0-9a-f]{10}\. /** Paths that can only be files. A miss on one of these is a 404 rather than the SPA shell. */ const STATIC_FILE_PATH = /\.(?:js|mjs|css|map|json|wasm|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp3|wav|ogg|lottie|pdf|txt|bcmap|br)$/i; +/** Files the web build also emits as a Brotli 11 twin (`foo.js.br`), which is smaller than Cloudflare's edge compression. */ +const PRECOMPRESSED_CONTENT_TYPES: Record = { + js: 'text/javascript', + css: 'text/css', + svg: 'image/svg+xml', + wasm: 'application/wasm', + ttf: 'font/ttf', +}; +const PRECOMPRESSED_PATH = /\.(?:js|css|svg|wasm|ttf)$/; + +const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; + const AASA_PATH = '/.well-known/apple-app-site-association'; const LEGACY_AASA_PATH = '/apple-app-site-association'; @@ -19,6 +31,25 @@ function isHTMLResponse(response: Response): boolean { return response.headers.get('Content-Type')?.startsWith('text/html') ?? false; } +function getOpaqueTag(etag: string): string { + return etag.trim().replace(/^W\//, '').replace(/^"|"$/g, ''); +} + +/** The twin and the original share the asset's ETag, so the twin gets its own to keep each representation's validator distinct. */ +function getBrotliETag(etag: string): string { + return `"${getOpaqueTag(etag)}-br"`; +} + +/** If-None-Match uses weak comparison, and Cloudflare weakens ETags on responses it compresses. */ +function matchesIfNoneMatch(request: Request, etag: string | null): boolean { + const ifNoneMatch = request.headers.get('If-None-Match'); + if (!ifNoneMatch || !etag) { + return false; + } + const opaqueTag = getOpaqueTag(etag); + return ifNoneMatch.split(',').some((candidate) => candidate.trim() === '*' || getOpaqueTag(candidate) === opaqueTag); +} + /** The asset router 307-redirects any other encoding (`@` -> `%40`), which would change the deep link the user opened. */ function toAssetRouterEncoding(pathname: string): string { return pathname @@ -33,13 +64,14 @@ function toAssetRouterEncoding(pathname: string): string { .join('/'); } -function buildAssetRequest(request: Request, url: URL): Request { +/** Conditional headers are dropped, because the Worker chooses the representation and answers revalidation itself. */ +function buildAssetRequest(request: Request, url: URL, pathSuffix = ''): Request { const assetURL = new URL(url); - assetURL.pathname = url.pathname === LEGACY_AASA_PATH ? AASA_PATH : toAssetRouterEncoding(url.pathname); - if (assetURL.pathname === url.pathname) { - return request; - } - return new Request(assetURL, request); + assetURL.pathname = (url.pathname === LEGACY_AASA_PATH ? AASA_PATH : toAssetRouterEncoding(url.pathname)) + pathSuffix; + const assetRequest = new Request(assetURL, request); + assetRequest.headers.delete('If-None-Match'); + assetRequest.headers.delete('If-Modified-Since'); + return assetRequest; } function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], nonce: string) { @@ -52,14 +84,47 @@ function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], n headers.set('Document-Policy', 'js-profiling'); } -function getPreviousBuildFileHeaders(object: R2Object): Headers { - const headers = new Headers({'Cache-Control': 'public, max-age=0, must-revalidate', ETag: object.httpEtag}); - object.writeHttpMetadata(headers); - return headers; +function createNotFoundResponse(environment: Env['ENVIRONMENT'], nonce: string): Response { + const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); + setSecurityHeaders(notFound.headers, environment, nonce); + return notFound; +} + +type ServedFile = { + response: Response; + isBrotli: boolean; +}; + +function createBrotliResponse(body: ReadableStream | null, headers: Headers, etag: string): Response { + headers.set('Content-Encoding', 'br'); + headers.set('ETag', getBrotliETag(etag)); + headers.delete('Content-Length'); + return new Response(body, {headers}); +} + +/** The asset, or its Brotli twin when the build has one. */ +async function getAsset(env: Env, request: Request, url: URL, mayHaveTwin: boolean): Promise { + const asset = await env.ASSETS.fetch(buildAssetRequest(request, url)); + if (!mayHaveTwin || asset.status !== 200 || isHTMLResponse(asset)) { + return {response: asset, isBrotli: false}; + } + + const twin = await env.ASSETS.fetch(buildAssetRequest(request, url, '.br')); + if (twin.status !== 200 || isHTMLResponse(twin)) { + await twin.body?.cancel(); + return {response: asset, isBrotli: false}; + } + + await asset.body?.cancel(); + return {response: createBrotliResponse(twin.body, new Headers(asset.headers), asset.headers.get('ETag') ?? twin.headers.get('ETag') ?? ''), isBrotli: true}; +} + +async function getPreviousBuildObject(bucket: R2Bucket, key: string, method: string): Promise { + return method === 'HEAD' ? bucket.head(key) : bucket.get(key); } /** A file uploaded by an earlier deploy, for tabs still running that build. Null when no build had it. */ -async function getPreviousBuildFile(env: Env, url: URL, method: string): Promise { +async function getPreviousBuildFile(env: Env, url: URL, method: string, mayHaveTwin: boolean): Promise { if (!env.PREVIOUS_BUILDS) { return null; } @@ -71,60 +136,91 @@ async function getPreviousBuildFile(env: Env, url: URL, method: string): Promise return null; } - if (method === 'HEAD') { - const object = await env.PREVIOUS_BUILDS.head(key); - return object ? new Response(null, {headers: getPreviousBuildFileHeaders(object)}) : null; + if (mayHaveTwin) { + const twin = await getPreviousBuildObject(env.PREVIOUS_BUILDS, `${key}.br`, method); + if (twin) { + const extension = key.slice(key.lastIndexOf('.') + 1); + // The twin's stored type describes the `.br` file, not the content it encodes. + const headers = new Headers({'Cache-Control': REVALIDATE_CACHE_CONTROL, 'Content-Type': PRECOMPRESSED_CONTENT_TYPES[extension] ?? 'application/octet-stream'}); + return {response: createBrotliResponse('body' in twin ? twin.body : null, headers, twin.httpEtag), isBrotli: true}; + } } - const object = await env.PREVIOUS_BUILDS.get(key); - return object ? new Response(object.body, {headers: getPreviousBuildFileHeaders(object)}) : null; + const object = await getPreviousBuildObject(env.PREVIOUS_BUILDS, key, method); + if (!object) { + return null; + } + const headers = new Headers({'Cache-Control': REVALIDATE_CACHE_CONTROL, ETag: object.httpEtag}); + object.writeHttpMetadata(headers); + return {response: new Response('body' in object ? object.body : null, {headers}), isBrotli: false}; } export default { async fetch(request, env): Promise { const url = new URL(request.url); const nonce = generateNonce(); + + // Twins are only served as an encoding of the original. + if (url.pathname.endsWith('.br')) { + return createNotFoundResponse(env.ENVIRONMENT, nonce); + } + const isNavigation = request.headers.get('Sec-Fetch-Mode') === 'navigate'; const isAASAPath = url.pathname === AASA_PATH || url.pathname === LEGACY_AASA_PATH; - let assetResponse = await env.ASSETS.fetch(buildAssetRequest(request, url)); + const isPrecompressed = PRECOMPRESSED_PATH.test(url.pathname); + // Cloudflare presents every request with `Accept-Encoding: br, gzip` and decodes the twin for clients without Brotli. + let served = await getAsset(env, request, url, isPrecompressed); // The SPA fallback answers every miss with index.html. A browser asking for a missing chunk needs the file from // an earlier build, or a real 404. - if (isHTMLResponse(assetResponse) && (isAASAPath || (!isNavigation && STATIC_FILE_PATH.test(url.pathname)))) { - const previousBuildFile = isAASAPath ? null : await getPreviousBuildFile(env, url, request.method); + if (isHTMLResponse(served.response) && (isAASAPath || (!isNavigation && STATIC_FILE_PATH.test(url.pathname)))) { + const previousBuildFile = isAASAPath ? null : await getPreviousBuildFile(env, url, request.method, isPrecompressed); if (!previousBuildFile) { - const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); - setSecurityHeaders(notFound.headers, env.ENVIRONMENT, nonce); - return notFound; + await served.response.body?.cancel(); + return createNotFoundResponse(env.ENVIRONMENT, nonce); } - assetResponse = previousBuildFile; + await served.response.body?.cancel(); + served = previousBuildFile; } - const response = new Response(assetResponse.body, assetResponse); - setSecurityHeaders(response.headers, env.ENVIRONMENT, nonce); + const {response: file, isBrotli} = served; + const headers = new Headers(file.headers); + setSecurityHeaders(headers, env.ENVIRONMENT, nonce); if (isAASAPath) { - response.headers.set('Content-Type', 'application/json'); + headers.set('Content-Type', 'application/json'); } if (HASHED_ASSET_PATH.test(url.pathname)) { - response.headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + headers.set('Cache-Control', 'public, max-age=31536000, immutable'); } - if (!isHTMLResponse(response)) { - return response; + if (!isHTMLResponse(file)) { + if (isPrecompressed) { + headers.append('Vary', 'Accept-Encoding'); + } + + if (matchesIfNoneMatch(request, headers.get('ETag'))) { + await file.body?.cancel(); + headers.delete('Content-Length'); + headers.delete('Content-Encoding'); + return new Response(null, {status: 304, headers}); + } + + // The twin is already Brotli-encoded, so Cloudflare must send its bytes as they are. + return new Response(file.body, {status: file.status, statusText: file.statusText, headers, encodeBody: isBrotli ? 'manual' : 'automatic'}); } // The nonce changes per request, so a 304 would pair a cached body with a CSP that no longer matches it. - response.headers.set('Cache-Control', 'no-cache'); - response.headers.delete('ETag'); - response.headers.delete('Last-Modified'); + headers.set('Cache-Control', 'no-cache'); + headers.delete('ETag'); + headers.delete('Last-Modified'); return new HTMLRewriter() .on(`script[nonce="${NONCE_PLACEHOLDER}"]`, { element(element) { element.setAttribute('nonce', nonce); }, }) - .transform(response); + .transform(new Response(file.body, {status: file.status, statusText: file.statusText, headers})); }, } satisfies ExportedHandler; diff --git a/web/cloudflare-worker/tests/createWorkerHarness.ts b/web/cloudflare-worker/tests/createWorkerHarness.ts index 41e3fd4fb4b2..9662d1397d4e 100644 --- a/web/cloudflare-worker/tests/createWorkerHarness.ts +++ b/web/cloudflare-worker/tests/createWorkerHarness.ts @@ -1,12 +1,16 @@ import {cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import path from 'node:path'; +import {brotliCompressSync} from 'node:zlib'; import {createTestHarness, experimental_readRawConfig} from 'wrangler'; const WORKER_DIR = path.resolve(import.meta.dirname, '..'); +const MAIN_BUNDLE = 'window.fixtureBundle = true;'; +const SERVICE_WORKER = 'self.fixtureServiceWorker = true;'; + /** A minimal stand-in for `dist/`, with one file per naming scheme or behavior the Worker treats differently. */ -const FAKE_WEB_BUILD: Record = { +const FAKE_WEB_BUILD: Record = { 'index.html': [ '', '', @@ -20,11 +24,12 @@ const FAKE_WEB_BUILD: Record = { ' ', '', ].join('\n'), - 'main-0123456789abcdef.bundle.js': 'window.fixtureBundle = true;', - 'main-0123456789abcdef.bundle.js.br': 'stands in for a Brotli twin', + 'main-0123456789abcdef.bundle.js': MAIN_BUNDLE, + 'main-0123456789abcdef.bundle.js.br': brotliCompressSync(MAIN_BUNDLE), 'main.0123456789.css': '#root {}', 'merged-source-map.js.map': '{"version":3,"sources":[],"mappings":""}', - 'service-worker.js': 'self.fixtureServiceWorker = true;', + 'service-worker.js': SERVICE_WORKER, + 'service-worker.js.br': brotliCompressSync(SERVICE_WORKER), 'workbox-01234567.js': 'self.fixtureWorkbox = true;', 'version.json': '{"version":"1.0.0-0"}', '.well-known/apple-app-site-association': '{"applinks":{"details":[]}}', @@ -32,13 +37,13 @@ const FAKE_WEB_BUILD: Record = { type PreviousBuildFile = { path: string; - contents: string; + contents: string | Uint8Array; contentType: string; }; /** The part of the R2 binding the tests use; the full Workers types conflict with Node's globals in this project. */ type PreviousBuildsEnv = { - PREVIOUS_BUILDS?: {put: (key: string, value: string, options: {httpMetadata: {contentType: string}}) => Promise}; + PREVIOUS_BUILDS?: {put: (key: string, value: string | Uint8Array, options: {httpMetadata: {contentType: string}}) => Promise}; }; type WorkerHarness = { diff --git a/web/cloudflare-worker/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts index 09997a2c11b4..5296749f0641 100644 --- a/web/cloudflare-worker/tests/worker.test.ts +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -3,6 +3,7 @@ import {createHash} from 'node:crypto'; import {readFileSync} from 'node:fs'; import path from 'node:path'; import {after, before, describe, test} from 'node:test'; +import {brotliCompressSync} from 'node:zlib'; import createWorkerHarness from './createWorkerHarness.ts'; @@ -185,13 +186,13 @@ describe('staging worker', () => { } }); - test('does not publish files listed in .assetsignore', async () => { - // Given the build contains Brotli twins and a merged source map - for (const path of [`${HASHED_BUNDLE_PATH}.br`, '/merged-source-map.js.map']) { - // When they are requested + test('does not serve source maps or Brotli twins at their own URLs', async () => { + // Given the build contains a merged source map, which .assetsignore keeps out of the upload, and Brotli twins + for (const path of ['/merged-source-map.js.map', `${HASHED_BUNDLE_PATH}.br`]) { + // When they are requested directly const response = await server.fetch(path); - // Then they are not served, since they were never uploaded as assets + // Then they are not served: maps must stay private, and twins are only served as an encoding of the original assert.equal(response.status, 404, path); } }); @@ -207,9 +208,87 @@ describe('staging worker', () => { }); }); +// The test harness decodes responses before the test reads them, so the twin is identified by its ETag. +describe('staging worker serving Brotli twins', () => { + const {server, cleanup} = createWorkerHarness('staging'); + const BROTLI = {'Accept-Encoding': 'br'}; + + before(async () => { + await server.listen(); + }); + + after(async () => { + await cleanup(); + }); + + async function getETag(path: string): Promise { + const response = await server.fetch(path, {method: 'HEAD', headers: BROTLI}); + return response.headers.get('ETag') ?? ''; + } + + test("serves the build's Brotli twin", async () => { + // Given a bundle the build also compressed with Brotli 11 + // When a browser requests it + const response = await server.fetch(HASHED_BUNDLE_PATH, {headers: BROTLI}); + + // Then it gets the twin, labeled with the original's type, so it downloads fewer bytes than edge compression gives + assert.equal(response.status, 200); + assert.equal(response.headers.get('mf-content-encoding'), 'br'); + assert.match(response.headers.get('ETag') ?? '', /-br"$/); + assert.match(response.headers.get('Content-Type') ?? '', /javascript/); + assert.match(response.headers.get('Vary') ?? '', /Accept-Encoding/); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=31536000, immutable'); + assert.equal(await response.text(), 'window.fixtureBundle = true;'); + assertSecurityHeaders(response); + }); + + test('answers revalidation with 304', async () => { + // Given files browsers revalidate on every load: one with a twin and one without + for (const path of ['/service-worker.js', '/version.json']) { + const etag = await getETag(path); + + // When the browser revalidates the copy it has, including after Cloudflare weakened the ETag + for (const ifNoneMatch of [etag, `W/${etag}`, `"something-else", ${etag}`]) { + const response = await server.fetch(path, {headers: {...BROTLI, 'If-None-Match': ifNoneMatch}}); + + // Then it gets a 304 with no body instead of downloading the file again + assert.equal(response.status, 304, `${path} ${ifNoneMatch}`); + assert.equal(await response.text(), '', `${path} ${ifNoneMatch}`); + assert.equal(response.headers.get('ETag'), etag, `${path} ${ifNoneMatch}`); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=0, must-revalidate', `${path} ${ifNoneMatch}`); + } + } + }); + + test("does not revalidate the twin with the original's ETag", async () => { + // Given a browser holding the original, whose ETag is the twin's without the `-br` suffix + const originalETag = (await getETag('/service-worker.js')).replace(/-br"$/, '"'); + + // When it revalidates + const response = await server.fetch('/service-worker.js', {headers: {...BROTLI, 'If-None-Match': originalETag}}); + + // Then it gets the twin in full, because its cached bytes are a different representation + assert.equal(response.status, 200); + assert.match(response.headers.get('ETag') ?? '', /-br"$/); + assert.equal(await response.text(), 'self.fixtureServiceWorker = true;'); + }); + + test('answers HEAD for a twin without a body', async () => { + // Given a bundle with a twin + // When it is requested with HEAD + const response = await server.fetch(HASHED_BUNDLE_PATH, {method: 'HEAD', headers: BROTLI}); + + // Then the headers describe the twin, without sending it + assert.equal(response.status, 200); + assert.match(response.headers.get('ETag') ?? '', /-br"$/); + assert.equal(await response.text(), ''); + }); +}); + describe('staging worker with files from previous builds', () => { const {server, addPreviousBuildFiles, cleanup} = createWorkerHarness('staging'); const PREVIOUS_CHUNK_PATH = '/main-aaaaaaaaaaaaaaaa.bundle.js'; + const PREVIOUS_TWINNED_CHUNK_PATH = '/main-bbbbbbbbbbbbbbbb.bundle.js'; before(async () => { await server.listen(); @@ -218,6 +297,8 @@ describe('staging worker with files from previous builds', () => { {path: 'canvaskit-0.40.0.wasm', contents: 'previous wasm', contentType: 'application/wasm'}, {path: HASHED_BUNDLE_PATH.slice(1), contents: 'window.previousCopyOfCurrentChunk = true;', contentType: 'text/javascript'}, {path: 'r/123/receipt.pdf', contents: 'not a page', contentType: 'application/pdf'}, + {path: PREVIOUS_TWINNED_CHUNK_PATH.slice(1), contents: 'window.previousTwinnedChunk = true;', contentType: 'text/javascript'}, + {path: `${PREVIOUS_TWINNED_CHUNK_PATH.slice(1)}.br`, contents: brotliCompressSync('window.previousTwinnedChunk = true;'), contentType: 'application/octet-stream'}, ]); }); @@ -279,6 +360,31 @@ describe('staging worker with files from previous builds', () => { assert.match(await response.text(), /New Expensify fixture/); }); + test("serves a previous build's Brotli twin", async () => { + // Given an older build's chunk and its twin in the bucket + // When a tab on that build requests the chunk + const response = await server.fetch(PREVIOUS_TWINNED_CHUNK_PATH, {headers: {'Accept-Encoding': 'br'}}); + + // Then it gets the twin, typed as JavaScript rather than with the twin's own stored type + assert.equal(response.status, 200); + assert.match(response.headers.get('ETag') ?? '', /-br"$/); + assert.equal(response.headers.get('Content-Type'), 'text/javascript'); + assert.match(response.headers.get('Vary') ?? '', /Accept-Encoding/); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=31536000, immutable'); + assert.equal(await response.text(), 'window.previousTwinnedChunk = true;'); + }); + + test('answers revalidation of a previous build file with 304', async () => { + // Given a browser holding a chunk served from the bucket + const {headers} = await server.fetch(PREVIOUS_TWINNED_CHUNK_PATH, {method: 'HEAD', headers: {'Accept-Encoding': 'br'}}); + + // When it revalidates + const response = await server.fetch(PREVIOUS_TWINNED_CHUNK_PATH, {headers: {'Accept-Encoding': 'br', 'If-None-Match': headers.get('ETag') ?? ''}}); + + // Then it gets a 304 + assert.equal(response.status, 304); + }); + test('returns 404 for a file no build has', async () => { // Given a chunk that is in neither the current build nor the bucket // When it is requested From 4c8542741469e5473773878cc5265893a0b6a445 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 20 Oct 2026 14:25:27 -0400 Subject: [PATCH 2/7] Upload Brotli twins to the previous builds bucket The Worker serves a previous build's twin from R2 when the current build no longer has the file. --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d2cebadd8ba8..79e8e4f659e7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -879,7 +879,7 @@ jobs: aws s3 cp dist s3://new-expensify-web-assets-staging/ --recursive \ --endpoint-url "https://$CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com" \ --no-progress \ - --exclude '*.br' --exclude '*.map' + --exclude '*.map' env: AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_SECRET_ACCESS_KEY }} From f4897391190c9e3de8a4985d3c64da12b2c92d52 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 20 Oct 2026 14:25:30 -0400 Subject: [PATCH 3/7] Document serving Brotli twins from the Worker --- web/cloudflare-worker/README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index bcdb5556a404..7eca0b361d94 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -14,11 +14,13 @@ Every request runs the Worker first (`run_worker_first: true`), which reads the | HTML (`/`, `/index.html`, any SPA deep link) | Each `