From 59a0072740aa19f8d2b7524b993a7be35ba67fce Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:53:34 +0100 Subject: [PATCH] feat: asset serving config for workers + assets (#6631) * add asset config behaviour move shared assets code to workers-shared/utils add asset config to wrangler add asset config to miniflare add config binding to asset-worker add asset config behaviour to AW (this is the big one) make UW manually handle 404s update assets-only fixture fix drop-trailing-slash logic html handling tests * pr feedback * fix tests and pr feedback 2 * changesets --------- Co-authored-by: Samuel Macleod Co-authored-by: Greg Brimble --- .changeset/green-pears-shake.md | 5 + .changeset/real-cheetahs-fry.md | 9 + fixtures/asset-config/README.md | 5 + fixtures/asset-config/env.d.ts | 8 + fixtures/asset-config/html-handling.test.ts | 830 ++++++++++++++++++ fixtures/asset-config/package.json | 24 + fixtures/asset-config/tsconfig.json | 15 + fixtures/asset-config/vitest.config.ts | 17 + .../workers-with-assets-only/public/404.html | 1 + .../public/about/404.html | 1 + fixtures/workers-with-assets-only/public/bin | 1 + .../workers-with-assets-only/public/bin.html | 1 + .../workers-with-assets-only/public/both.html | 1 + .../public/both/index.html | 1 + .../workers-with-assets-only/public/file-bin | 1 + .../public/file-bin.html | 1 + .../workers-with-assets-only/public/file.html | 1 + .../public/folder-bin | 0 .../public/folder/index.html | 1 + .../public/index-bin/index | 1 + .../public/index-bin/index.html | 1 + .../tests/index.test.ts | 15 +- .../workers-with-assets-only/wrangler.toml | 3 + .../workers-with-assets/tests/index.test.ts | 15 +- .../miniflare/src/plugins/assets/index.ts | 68 +- .../miniflare/src/plugins/assets/schema.ts | 9 +- .../src/workers/assets/assets.worker.ts | 3 +- .../src/workers/assets/router.worker.ts | 3 +- .../asset-worker/src/assets-manifest.ts | 24 +- .../asset-worker/src/configuration.ts | 10 + .../asset-worker/src/global.d.ts | 9 - .../asset-worker/src/handler.ts | 587 +++++++++++++ .../workers-shared/asset-worker/src/index.ts | 92 +- .../asset-worker/src/responses.ts | 21 +- .../asset-worker/src/utils/headers.ts | 38 +- .../asset-worker/tests/headers.test.ts | 143 --- .../workers-shared/asset-worker/wrangler.toml | 16 +- packages/workers-shared/index.ts | 3 + packages/workers-shared/package.json | 11 +- .../workers-shared/router-worker/src/index.ts | 19 +- .../router-worker/wrangler.toml | 17 +- packages/workers-shared/turbo.json | 8 + packages/workers-shared/utils/constants.ts | 29 + packages/workers-shared/utils/helpers.ts | 26 + packages/workers-shared/utils/types.ts | 28 +- packages/wrangler/scripts/deps.ts | 1 - .../src/__tests__/configuration.test.ts | 48 + .../wrangler/src/__tests__/deploy.test.ts | 2 + .../__tests__/helpers/mock-upload-worker.ts | 5 +- packages/wrangler/src/config/environment.ts | 6 + packages/wrangler/src/config/validation.ts | 27 + packages/wrangler/src/deploy/deploy.ts | 24 +- .../create-worker-upload-form.ts | 31 +- .../wrangler/src/deployment-bundle/worker.ts | 3 +- packages/wrangler/src/dev/miniflare.ts | 1 + packages/wrangler/src/experimental-assets.ts | 54 +- packages/wrangler/src/versions/upload.ts | 24 +- pnpm-lock.yaml | 253 +++++- 58 files changed, 2201 insertions(+), 400 deletions(-) create mode 100644 .changeset/green-pears-shake.md create mode 100644 .changeset/real-cheetahs-fry.md create mode 100644 fixtures/asset-config/README.md create mode 100644 fixtures/asset-config/env.d.ts create mode 100644 fixtures/asset-config/html-handling.test.ts create mode 100644 fixtures/asset-config/package.json create mode 100644 fixtures/asset-config/tsconfig.json create mode 100644 fixtures/asset-config/vitest.config.ts create mode 100644 fixtures/workers-with-assets-only/public/404.html create mode 100644 fixtures/workers-with-assets-only/public/about/404.html create mode 100644 fixtures/workers-with-assets-only/public/bin create mode 100644 fixtures/workers-with-assets-only/public/bin.html create mode 100644 fixtures/workers-with-assets-only/public/both.html create mode 100644 fixtures/workers-with-assets-only/public/both/index.html create mode 100644 fixtures/workers-with-assets-only/public/file-bin create mode 100644 fixtures/workers-with-assets-only/public/file-bin.html create mode 100644 fixtures/workers-with-assets-only/public/file.html create mode 100644 fixtures/workers-with-assets-only/public/folder-bin create mode 100644 fixtures/workers-with-assets-only/public/folder/index.html create mode 100644 fixtures/workers-with-assets-only/public/index-bin/index create mode 100644 fixtures/workers-with-assets-only/public/index-bin/index.html create mode 100644 packages/workers-shared/asset-worker/src/configuration.ts delete mode 100644 packages/workers-shared/asset-worker/src/global.d.ts create mode 100644 packages/workers-shared/asset-worker/src/handler.ts delete mode 100644 packages/workers-shared/asset-worker/tests/headers.test.ts create mode 100644 packages/workers-shared/index.ts create mode 100644 packages/workers-shared/utils/constants.ts create mode 100644 packages/workers-shared/utils/helpers.ts diff --git a/.changeset/green-pears-shake.md b/.changeset/green-pears-shake.md new file mode 100644 index 000000000000..5142696cdf1b --- /dev/null +++ b/.changeset/green-pears-shake.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +Add config options 'html_handling' and 'not_found_handling' to experimental_asset field in wrangler.toml diff --git a/.changeset/real-cheetahs-fry.md b/.changeset/real-cheetahs-fry.md new file mode 100644 index 000000000000..f4ee6ecd840a --- /dev/null +++ b/.changeset/real-cheetahs-fry.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/workers-shared": minor +--- + +Add asset config behaviour. + +Add `html_handling` (e.g. /index.html -> /) with options `"auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"` to Asset Worker. + +Add `not_found_handling` behaviour with options `"404-page" | "single-page-application" | "none"` to Asset Worker. diff --git a/fixtures/asset-config/README.md b/fixtures/asset-config/README.md new file mode 100644 index 000000000000..65429521ac8c --- /dev/null +++ b/fixtures/asset-config/README.md @@ -0,0 +1,5 @@ +# Asset config fixture + +This is testing the asset worker in the workers-shared package. We cannot use the vitest integration directly in that package because it results in circular dependencies. + +Since it runs tests in a workerd context and doesn't depend on the host OS, this fixture only runs test on Linux to reduce wasted time in CI. diff --git a/fixtures/asset-config/env.d.ts b/fixtures/asset-config/env.d.ts new file mode 100644 index 000000000000..ec168ef521c9 --- /dev/null +++ b/fixtures/asset-config/env.d.ts @@ -0,0 +1,8 @@ +declare module "cloudflare:test" { + // Controls the type of `import("cloudflare:test").env` + interface ProvidedEnv { + CONFIG: Record; + ASSETS_MANIFEST: ArrayBuffer; + ASSETS_KV_NAMESPACE: KVNamespace; + } +} diff --git a/fixtures/asset-config/html-handling.test.ts b/fixtures/asset-config/html-handling.test.ts new file mode 100644 index 000000000000..8576b31d939d --- /dev/null +++ b/fixtures/asset-config/html-handling.test.ts @@ -0,0 +1,830 @@ +import { SELF } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration"; +import Worker from "../../packages/workers-shared/asset-worker/src/index"; +import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv"; +import type { AssetMetadata } from "../../packages/workers-shared/asset-worker/src/utils/kv"; + +const IncomingRequest = Request; + +vi.mock("../../packages/workers-shared/asset-worker/src/utils/kv.ts"); +vi.mock("../../packages/workers-shared/asset-worker/src/configuration"); +const existsMock = (fileList: Set) => { + vi.spyOn(Worker.prototype, "exists").mockImplementation( + async (pathname: string) => { + if (fileList.has(pathname)) { + return pathname; + } + } + ); +}; +const BASE_URL = "http://example.com"; + +type TestCase = { + title: string; + files: string[]; + requestPath: string; + matchedFile?: string; + finalPath?: string; +}; + +const testCases: { + html_handling: + | "auto-trailing-slash" + | "drop-trailing-slash" + | "force-trailing-slash" + | "none"; + cases: TestCase[]; +}[] = [ + { + html_handling: "auto-trailing-slash", + cases: [ + { + title: "/ -> 200 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index.html -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/both -> 200 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both", + matchedFile: "/both.html", + finalPath: "/both", + }, + { + title: "/both.html -> /both 307 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both.html", + matchedFile: "/both.html", + finalPath: "/both", + }, + { + title: "/both/ -> 200 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + { + title: "/both/index.html -> /both/ 307 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index.html", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + { + title: "/both/index -> /both/ 307 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + { + title: "/file -> 200 (with file.html)", + files: ["/file.html"], + requestPath: "/file", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file.html -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file.html", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/ -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/index -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/index.html -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index.html", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/folder -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder.html -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder.html", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/ -> 200 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/index -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/index.html -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index.html", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/bin -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin.html -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin.html", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin%2F -> 200 (with /bin%2F)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin%2F", + matchedFile: "/bin%2F", + finalPath: "/bin%2F", + }, + { + title: "/bin/ -> 200 (with /bin/index.html not /bin%2F", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin/index -> 307 /bin/ (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin/index.html -> 307 /bin/ (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index.html", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + // prefers exact match + { + title: "/file-bin -> 200 ", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin", + matchedFile: "/file-bin", + finalPath: "/file-bin", + }, + // (doesn't rewrite if resulting path would match another asset) + { + title: "/file-bin.html -> 200 ", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin.html", + matchedFile: "/file-bin.html", + finalPath: "/file-bin.html", + }, + // (finds file-bin.html --rewrite--> /file-bin, but /file-bin exists) + { + title: "/file-bin/ -> 404 ", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/", + }, + { + title: "/file-bin/index -> 404 ", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index", + }, + { + title: "/file-bin/index.html -> 404 ", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index.html", + }, + ], + }, + { + html_handling: "drop-trailing-slash", + cases: [ + // note that we don't drop the "/" if that is the only path component + { + title: "/ -> 200 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index.html -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/both -> 200 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both", + matchedFile: "/both.html", + finalPath: "/both", + }, + { + title: "/both.html -> /both 307 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both.html", + matchedFile: "/both.html", + finalPath: "/both", + }, + // drops trailing slash and so it tries /both.html first + { + title: "/both/ -> /both 307 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/", + matchedFile: "/both.html", + finalPath: "/both", + }, + { + title: "/both/index -> 307 (with /both.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index", + matchedFile: "/both.html", + finalPath: "/both", + }, + // can't rewrite /both/index.html: would be /both/ -> /both -> /both.html + // ie can only access /both/index.html by exact match + { + title: "/both/index.html -> 200 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index.html", + matchedFile: "/both/index.html", + finalPath: "/both/index.html", + }, + { + title: "/file -> 200 (with file.html)", + files: ["/file.html"], + requestPath: "/file", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file.html -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file.html", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/ -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/index -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/file/index.html -> /file 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index.html", + matchedFile: "/file.html", + finalPath: "/file", + }, + { + title: "/folder -> 200 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder", + matchedFile: "/folder/index.html", + finalPath: "/folder", + }, + { + title: "/folder.html -> /folder 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder.html", + matchedFile: "/folder/index.html", + finalPath: "/folder", + }, + { + title: "/folder/ -> /folder 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/", + matchedFile: "/folder/index.html", + finalPath: "/folder", + }, + { + title: "/folder/index -> /folder 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index", + matchedFile: "/folder/index.html", + finalPath: "/folder", + }, + { + title: "/folder/index.html -> /folder 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index.html", + matchedFile: "/folder/index.html", + finalPath: "/folder", + }, + { + title: "/bin -> 200 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin", + matchedFile: "/bin/index.html", + finalPath: "/bin", + }, + { + title: "/bin.html -> /bin 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin.html", + matchedFile: "/bin/index.html", + finalPath: "/bin", + }, + { + title: "/bin%2F -> 200 (with /bin%2F)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin%2F", + matchedFile: "/bin%2F", + finalPath: "/bin%2F", + }, + { + title: "/bin/ -> /bin 307 (with /bin/index.html not /bin%2F", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/", + matchedFile: "/bin/index.html", + finalPath: "/bin", + }, + { + title: "/bin/index -> /bin 307 (with /bin/index.html not /bin%2F", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index", + matchedFile: "/bin/index.html", + finalPath: "/bin", + }, + { + title: "/bin/index.html -> /bin 307 (with /bin/index.html not /bin%2F", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index.html", + matchedFile: "/bin/index.html", + finalPath: "/bin", + }, + { + title: "/file-bin -> 200", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin", + matchedFile: "/file-bin", + finalPath: "/file-bin", + }, + // doesn't redirect to /file-bin because that also exists + { + title: "/file-bin.html -> 200 (with /file-bin.html)", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin.html", + matchedFile: "/file-bin.html", + finalPath: "/file-bin.html", + }, + // 404s because ambiguity between /file-bin or /file-bin.html? + { + title: "/file-bin/ -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/", + }, + { + title: "/file-bin/index -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index", + }, + { + title: "/file-bin/index.html -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index.html", + }, + ], + }, + { + html_handling: "force-trailing-slash", + cases: [ + { + title: "/ -> 200 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index", + matchedFile: "/index.html", + finalPath: "/", + }, + { + title: "/index.html -> / 307 (with /index.html)", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/", + }, + // ie tries /both/index.html first + { + title: "/both -> /both/ 307 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + // can't rewrite /both.html: would be /both -> /both/ -> /both/index.html + // ie can only access /both.html by exact match + { + title: "/both.html -> 200", + files: ["/both.html", "/both/index.html"], + requestPath: "/both.html", + matchedFile: "/both.html", + finalPath: "/both.html", + }, + { + title: "/both/ -> 200 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + { + title: "/both/index -> /both/ 307 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + { + title: "/both/index.html -> /both/ 307 (with /both/index.html)", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index.html", + matchedFile: "/both/index.html", + finalPath: "/both/", + }, + // always ends in a trailing slash + { + title: "/file -> /file/ 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file", + matchedFile: "/file.html", + finalPath: "/file/", + }, + { + title: "/file.html -> /file/ 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file.html", + matchedFile: "/file.html", + finalPath: "/file/", + }, + + { + title: "/file/ -> 200 (with file.html)", + files: ["/file.html"], + requestPath: "/file/", + matchedFile: "/file.html", + finalPath: "/file/", + }, + { + title: "/file/index -> /file/ 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index", + matchedFile: "/file.html", + finalPath: "/file/", + }, + { + title: "/file/index.html -> /file/ 307 (with file.html)", + files: ["/file.html"], + requestPath: "/file/index.html", + matchedFile: "/file.html", + finalPath: "/file/", + }, + { + title: "/folder -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder.html -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder.html", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/ -> 200 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/index -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/folder/index.html -> /folder/ 307 (with /folder/index.html)", + files: ["/folder/index.html"], + requestPath: "/folder/index.html", + matchedFile: "/folder/index.html", + finalPath: "/folder/", + }, + { + title: "/bin -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin.html -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin.html", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin%2F -> 200 (with /bin%2F)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin%2F", + matchedFile: "/bin%2F", + finalPath: "/bin%2F", + }, + { + title: "/bin/ -> 200 (with /bin/index.html not /bin%2F", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin/index -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + { + title: "/bin/index.html -> /bin/ 307 (with /bin/index.html)", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index.html", + matchedFile: "/bin/index.html", + finalPath: "/bin/", + }, + // doesn't force a trailing slash here because it would redirect to /file-bin.html + { + title: "/file-bin -> 200", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin", + matchedFile: "/file-bin", + finalPath: "/file-bin", + }, + { + title: "/file-bin.html -> /file-bin/ 307 (with /file-bin.html)", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin.html", + matchedFile: "/file-bin.html", + finalPath: "/file-bin/", + }, + { + title: "/file-bin/ -> 200", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/", + matchedFile: "/file-bin.html", + finalPath: "/file-bin/", + }, + { + title: "/file-bin/index -> /file-bin/ 307 (with /file-bin.html)", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index", + matchedFile: "/file-bin.html", + finalPath: "/file-bin/", + }, + { + title: "/file-bin/index.html -> /file-bin/ 307 (with /file-bin.html)", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index.html", + matchedFile: "/file-bin.html", + finalPath: "/file-bin/", + }, + ], + }, + { + html_handling: "none", + cases: [ + { + title: "/ -> 404", + files: ["/index.html"], + requestPath: "/", + }, + { + title: "/index -> 404", + files: ["/index.html"], + requestPath: "/index", + }, + { + title: "/index.html -> 200", + files: ["/index.html"], + requestPath: "/index.html", + matchedFile: "/index.html", + finalPath: "/index.html", + }, + { + title: "/both -> 404", + files: ["/both.html", "/both/index.html"], + requestPath: "/both", + }, + { + title: "/both.html -> 200", + files: ["/both.html", "/both/index.html"], + requestPath: "/both.html", + matchedFile: "/both.html", + finalPath: "/both.html", + }, + { + title: "/both/ -> 404", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/", + }, + { + title: "/both/index.html -> 200", + files: ["/both.html", "/both/index.html"], + requestPath: "/both/index.html", + matchedFile: "/both/index.html", + finalPath: "/both/index.html", + }, + { + title: "/file/index.html -> 404", + files: ["/file.html"], + requestPath: "/file/index.html", + }, + { + title: "/folder.html -> 404", + files: ["/folder/index.html"], + requestPath: "/folder.html", + }, + { + title: "/bin -> 404", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin", + }, + { + title: "/bin.html -> 404", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin.html", + }, + { + title: "/bin%2F -> 200", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin%2F", + matchedFile: "/bin%2F", + finalPath: "/bin%2F", + }, + { + title: "/bin/ -> 404", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/", + }, + { + title: "/bin/index -> 404", + files: ["/bin%2F", "/bin/index.html"], + requestPath: "/bin/index", + }, + { + title: "/file-bin -> 200", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin", + matchedFile: "/file-bin", + finalPath: "/file-bin", + }, + { + title: "/file-bin.html -> 200", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin.html", + matchedFile: "/file-bin.html", + finalPath: "/file-bin.html", + }, + { + title: "/file-bin/ -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/", + }, + { + title: "/file-bin/index -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index", + }, + { + title: "/file-bin/index.html -> 404", + files: ["/file-bin", "/file-bin.html"], + requestPath: "/file-bin/index.html", + }, + ], + }, +]; + +describe("htmlHanding options", () => { + beforeEach(() => { + vi.mocked(getAssetWithMetadataFromKV).mockImplementation( + () => + Promise.resolve({ + value: "no-op", + metadata: { + contentType: "no-op", + }, + }) as unknown as Promise< + KVNamespaceGetWithMetadataResult + > + ); + }); + afterEach(() => { + vi.mocked(getAssetWithMetadataFromKV).mockRestore(); + }); + describe.each(testCases)(`$html_handling`, ({ html_handling, cases }) => { + beforeEach(() => { + vi.mocked(applyConfigurationDefaults).mockImplementation(() => { + return { + html_handling, + not_found_handling: "none", + }; + }); + }); + it.each(cases)( + "$title", + async ({ files, requestPath, matchedFile, finalPath }) => { + existsMock(new Set(files)); + const request = new IncomingRequest(BASE_URL + requestPath); + let response = await SELF.fetch(request); + if (matchedFile && finalPath) { + expect(getAssetWithMetadataFromKV).toBeCalledTimes(1); + expect(getAssetWithMetadataFromKV).toBeCalledWith( + undefined, + matchedFile + ); + console.dir(response.status); + expect(response.status).toBe(200); + expect(response.url).toBe(BASE_URL + finalPath); + // can't check intermediate 307 directly: + expect(response.redirected).toBe(requestPath !== finalPath); + } else { + expect(getAssetWithMetadataFromKV).not.toBeCalled(); + expect(response.status).toBe(404); + } + } + ); + }); +}); diff --git a/fixtures/asset-config/package.json b/fixtures/asset-config/package.json new file mode 100644 index 000000000000..f2d4cf15d102 --- /dev/null +++ b/fixtures/asset-config/package.json @@ -0,0 +1,24 @@ +{ + "name": "workers-assets-config-test", + "private": true, + "scripts": { + "dev": "npx wrangler dev", + "test:ci": "run-script-os", + "test:ci:default": "true", + "test:ci:nix": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tsconfig.json" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.4.29", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "^4.20240821.1", + "run-script-os": "^1.1.6", + "undici": "^5.28.4", + "vitest": "1.5.0", + "wrangler": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/fixtures/asset-config/tsconfig.json b/fixtures/asset-config/tsconfig.json new file mode 100644 index 000000000000..48f509607b68 --- /dev/null +++ b/fixtures/asset-config/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "lib": ["ES2020"], + "types": [ + "@cloudflare/workers-types/experimental", + "@cloudflare/vitest-pool-workers" + ], + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/asset-config/vitest.config.ts b/fixtures/asset-config/vitest.config.ts new file mode 100644 index 000000000000..6fe68115b744 --- /dev/null +++ b/fixtures/asset-config/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + chaiConfig: { + truncateThreshold: 80, + }, + poolOptions: { + workers: { + wrangler: { + configPath: + "../../packages/workers-shared/asset-worker/wrangler.toml", + }, + }, + }, + }, +}); diff --git a/fixtures/workers-with-assets-only/public/404.html b/fixtures/workers-with-assets-only/public/404.html new file mode 100644 index 000000000000..d5436c864683 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/404.html @@ -0,0 +1 @@ +

Not Found page

diff --git a/fixtures/workers-with-assets-only/public/about/404.html b/fixtures/workers-with-assets-only/public/about/404.html new file mode 100644 index 000000000000..83c2d40c64b1 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/about/404.html @@ -0,0 +1 @@ +

About's 404 page

diff --git a/fixtures/workers-with-assets-only/public/bin b/fixtures/workers-with-assets-only/public/bin new file mode 100644 index 000000000000..771a6c84aed7 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/bin @@ -0,0 +1 @@ +some-binary-file \ No newline at end of file diff --git a/fixtures/workers-with-assets-only/public/bin.html b/fixtures/workers-with-assets-only/public/bin.html new file mode 100644 index 000000000000..a10f573fd26d --- /dev/null +++ b/fixtures/workers-with-assets-only/public/bin.html @@ -0,0 +1 @@ +bin.html diff --git a/fixtures/workers-with-assets-only/public/both.html b/fixtures/workers-with-assets-only/public/both.html new file mode 100644 index 000000000000..98fe32c3e37a --- /dev/null +++ b/fixtures/workers-with-assets-only/public/both.html @@ -0,0 +1 @@ +both.html diff --git a/fixtures/workers-with-assets-only/public/both/index.html b/fixtures/workers-with-assets-only/public/both/index.html new file mode 100644 index 000000000000..376a5a9e23d5 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/both/index.html @@ -0,0 +1 @@ +/both/index.html diff --git a/fixtures/workers-with-assets-only/public/file-bin b/fixtures/workers-with-assets-only/public/file-bin new file mode 100644 index 000000000000..b4320c3ca3fc --- /dev/null +++ b/fixtures/workers-with-assets-only/public/file-bin @@ -0,0 +1 @@ +file-bin \ No newline at end of file diff --git a/fixtures/workers-with-assets-only/public/file-bin.html b/fixtures/workers-with-assets-only/public/file-bin.html new file mode 100644 index 000000000000..7bd5f328fe11 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/file-bin.html @@ -0,0 +1 @@ +file-bin.html diff --git a/fixtures/workers-with-assets-only/public/file.html b/fixtures/workers-with-assets-only/public/file.html new file mode 100644 index 000000000000..e9e7c7fc7ea2 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/file.html @@ -0,0 +1 @@ +file.html diff --git a/fixtures/workers-with-assets-only/public/folder-bin b/fixtures/workers-with-assets-only/public/folder-bin new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/fixtures/workers-with-assets-only/public/folder/index.html b/fixtures/workers-with-assets-only/public/folder/index.html new file mode 100644 index 000000000000..299ace561c23 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/folder/index.html @@ -0,0 +1 @@ +folder/index.html diff --git a/fixtures/workers-with-assets-only/public/index-bin/index b/fixtures/workers-with-assets-only/public/index-bin/index new file mode 100644 index 000000000000..9383e7940f07 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/index-bin/index @@ -0,0 +1 @@ +index-bin/index \ No newline at end of file diff --git a/fixtures/workers-with-assets-only/public/index-bin/index.html b/fixtures/workers-with-assets-only/public/index-bin/index.html new file mode 100644 index 000000000000..5792f9635705 --- /dev/null +++ b/fixtures/workers-with-assets-only/public/index-bin/index.html @@ -0,0 +1 @@ +index-bin/index.html diff --git a/fixtures/workers-with-assets-only/tests/index.test.ts b/fixtures/workers-with-assets-only/tests/index.test.ts index d933a82560d6..9737d11b1dba 100644 --- a/fixtures/workers-with-assets-only/tests/index.test.ts +++ b/fixtures/workers-with-assets-only/tests/index.test.ts @@ -29,9 +29,12 @@ describe("[Workers + Assets] static-assets only site`", () => { expect(text).toContain(`

Learn more about Workers with Assets soon!

`); }); - it("should not resolve '/' to '/index.html' ", async ({ expect }) => { + it("should resolve '/' to '/index.html' ", async ({ expect }) => { let response = await fetch(`http://${ip}:${port}/`); - expect(response.status).toBe(404); + expect(response.status).toBe(200); + expect(await response.text()).toContain( + `

Hello Workers + Assets World 🚀!

` + ); }); it("should 404 if asset is not found in the asset manifest", async ({ @@ -73,19 +76,13 @@ describe("[Workers + Assets] static-assets only site`", () => { expect(response.headers.get("Content-Type")).toBe("image/jpeg"); }); - it("should return 405 for non-GET requests if asset route exists", async ({ + it("should return 405 for non-GET or HEAD requests if asset route exists", async ({ expect, }) => { // as per https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods // excl. TRACE and CONNECT which are not supported let response = await fetch(`http://${ip}:${port}/index.html`, { - method: "HEAD", - }); - expect(response.status).toBe(405); - expect(response.statusText).toBe("Method Not Allowed"); - - response = await fetch(`http://${ip}:${port}/index.html`, { method: "POST", }); expect(response.status).toBe(405); diff --git a/fixtures/workers-with-assets-only/wrangler.toml b/fixtures/workers-with-assets-only/wrangler.toml index ab54b1a45381..486ec72b13c8 100644 --- a/fixtures/workers-with-assets-only/wrangler.toml +++ b/fixtures/workers-with-assets-only/wrangler.toml @@ -3,3 +3,6 @@ compatibility_date = "2024-01-01" [experimental_assets] directory = "./public" +serve_exact_matches_only = false +trailing_slashes = "remove" +not_found_behavior = "404-page" \ No newline at end of file diff --git a/fixtures/workers-with-assets/tests/index.test.ts b/fixtures/workers-with-assets/tests/index.test.ts index b2c8d900ad29..1c620282574b 100644 --- a/fixtures/workers-with-assets/tests/index.test.ts +++ b/fixtures/workers-with-assets/tests/index.test.ts @@ -40,12 +40,11 @@ describe("[Workers + Assets] dynamic site", () => { ); }); - it("should not `/` resolve to `/index.html` ", async ({ expect }) => { + // html_handling defaults to 'auto-trailing-slash' + it("should `/` resolve to `/index.html` ", async ({ expect }) => { const response = await fetch(`http://${ip}:${port}/`); const text = await response.text(); - expect(text).toContain( - "There were no assets at this route! Hello from the user Worker instead!" - ); + expect(text).toContain("

Hello Workers + Assets World 🚀!

"); }); it("should handle content types correctly on asset routes", async ({ @@ -79,7 +78,7 @@ describe("[Workers + Assets] dynamic site", () => { expect(response.headers.get("Content-Type")).toBe("image/jpeg"); }); - it("should return 405 for non-GET requests on routes where assets exist", async ({ + it("should return 405 for non-GET or HEAD requests on routes where assets exist", async ({ expect, }) => { // these should return the error and NOT be forwarded onto the user Worker @@ -89,12 +88,6 @@ describe("[Workers + Assets] dynamic site", () => { // excl. TRACE and CONNECT which are not supported let response = await fetch(`http://${ip}:${port}/index.html`, { - method: "HEAD", - }); - expect(response.status).toBe(405); - expect(response.statusText).toBe("Method Not Allowed"); - - response = await fetch(`http://${ip}:${port}/index.html`, { method: "POST", }); expect(response.status).toBe(405); diff --git a/packages/miniflare/src/plugins/assets/index.ts b/packages/miniflare/src/plugins/assets/index.ts index e00ee56acf45..f9db7ac28646 100644 --- a/packages/miniflare/src/plugins/assets/index.ts +++ b/packages/miniflare/src/plugins/assets/index.ts @@ -1,7 +1,17 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { getType } from "mime"; +import { + CONTENT_HASH_OFFSET, + encodeFilePath, + ENTRY_SIZE, + getContentType, + HEADER_SIZE, + MAX_ASSET_COUNT, + MAX_ASSET_SIZE, + PATH_HASH_OFFSET, + PATH_HASH_SIZE, +} from "@cloudflare/workers-shared"; import prettyBytes from "pretty-bytes"; import SCRIPT_ASSETS from "worker:assets/assets"; import SCRIPT_ASSETS_KV from "worker:assets/assets-kv"; @@ -77,7 +87,6 @@ export const ASSETS_PLUGIN: Plugin = { ], }, }; - const assetsManifest = await buildAssetsManifest(options.assets.path); const assetService: Service = { name: ASSETS_SERVICE_NAME, @@ -98,6 +107,10 @@ export const ASSETS_PLUGIN: Plugin = { name: "ASSETS_MANIFEST", data: assetsManifest, }, + { + name: "CONFIG", + json: JSON.stringify(options.assets.assetConfig), + }, ], }, }; @@ -144,20 +157,6 @@ export const ASSETS_PLUGIN: Plugin = { // 2. Sort and binary encode the asset manifest // This is available to asset service worker as a binding. -const MAX_ASSET_COUNT = 20_000; -const MAX_ASSET_SIZE = 25 * 1024 * 1024; -const MANIFEST_HEADER_SIZE = 20; - -const PATH_HASH_OFFSET = 0; -const PATH_HASH_SIZE = 16; - -const CONTENT_HASH_OFFSET = PATH_HASH_SIZE; -const CONTENT_HASH_SIZE = 16; - -const TAIL_RESERVED_SIZE = 8; - -const ENTRY_SIZE = PATH_HASH_SIZE + CONTENT_HASH_SIZE + TAIL_RESERVED_SIZE; - export const buildAssetsManifest = async (dir: string) => { const manifest = await walk(dir); const sortedAssetManifest = sortManifest(manifest); @@ -197,7 +196,9 @@ const walk = async (dir: string) => { ); } - manifest.push(await hashPath(encodeFilePath(relativeFilepath))); + manifest.push( + await hashPath(encodeFilePath(relativeFilepath, path.sep)) + ); counter++; } }) @@ -211,22 +212,6 @@ const walk = async (dir: string) => { } return manifest; }; - -const hashPath = async (path: string) => { - const encoder = new TextEncoder(); - const data = encoder.encode(path); - const hashBuffer = await crypto.subtle.digest("SHA-256", data.buffer); - return new Uint8Array(hashBuffer, 0, PATH_HASH_SIZE); -}; - -const encodeFilePath = (filePath: string) => { - const encodedPath = filePath - .split(path.sep) - .map((segment) => encodeURIComponent(segment)) - .join("/"); - return "/" + encodedPath; -}; - // sorts ascending by path hash const sortManifest = (manifest: Uint8Array[]) => { return manifest.sort(comparisonFn); @@ -253,10 +238,10 @@ const comparisonFn = (a: Uint8Array, b: Uint8Array) => { const encodeManifest = (manifest: Uint8Array[]) => { const assetManifestBytes = new Uint8Array( - MANIFEST_HEADER_SIZE + manifest.length * ENTRY_SIZE + HEADER_SIZE + manifest.length * ENTRY_SIZE ); for (const [i, entry] of manifest.entries()) { - const entryOffset = MANIFEST_HEADER_SIZE + i * ENTRY_SIZE; + const entryOffset = HEADER_SIZE + i * ENTRY_SIZE; // NB: PATH_HASH_OFFSET = 0 // set the path hash: assetManifestBytes.set(entry, entryOffset + PATH_HASH_OFFSET); @@ -279,6 +264,7 @@ type AssetReverseMap = { [pathHash: string]: { filePath: string; contentType: string }; }; +// TODO: This walk should be pulled into a common place, shared by wrangler and miniflare const createReverseMap = async (dir: string) => { const files = await fs.readdir(dir, { recursive: true }); const assetsReverseMap: AssetReverseMap = {}; @@ -292,11 +278,12 @@ const createReverseMap = async (dir: string) => { return; } else { const pathHash = bytesToHex( - await hashPath(encodeFilePath(relativeFilepath)) + await hashPath(encodeFilePath(relativeFilepath, path.sep)) ); + assetsReverseMap[pathHash] = { filePath: relativeFilepath, - contentType: getType(filepath) ?? "application/octet-stream", + contentType: getContentType(filepath), }; } }) @@ -309,3 +296,10 @@ const bytesToHex = (buffer: ArrayBufferLike) => { .map((b) => b.toString(16).padStart(2, "0")) .join(""); }; + +const hashPath = async (path: string) => { + const encoder = new TextEncoder(); + const data = encoder.encode(path); + const hashBuffer = await crypto.subtle.digest("SHA-256", data.buffer); + return new Uint8Array(hashBuffer, 0, PATH_HASH_SIZE); +}; diff --git a/packages/miniflare/src/plugins/assets/schema.ts b/packages/miniflare/src/plugins/assets/schema.ts index c130c024c6c2..e82e6f8b5462 100644 --- a/packages/miniflare/src/plugins/assets/schema.ts +++ b/packages/miniflare/src/plugins/assets/schema.ts @@ -1,10 +1,10 @@ +import { + AssetConfigSchema, + RoutingConfigSchema, +} from "@cloudflare/workers-shared"; import { z } from "zod"; import { PathSchema } from "../../shared"; -export const RoutingConfigSchema = z.object({ - hasUserWorker: z.boolean(), -}); - export const AssetsOptionsSchema = z.object({ assets: z .object({ @@ -12,6 +12,7 @@ export const AssetsOptionsSchema = z.object({ path: PathSchema, bindingName: z.string().optional(), routingConfig: RoutingConfigSchema, + assetConfig: AssetConfigSchema, }) .optional(), }); diff --git a/packages/miniflare/src/workers/assets/assets.worker.ts b/packages/miniflare/src/workers/assets/assets.worker.ts index 4d901509b957..523227926cc0 100644 --- a/packages/miniflare/src/workers/assets/assets.worker.ts +++ b/packages/miniflare/src/workers/assets/assets.worker.ts @@ -1,5 +1,4 @@ -// @ts-ignore the fact that there are no typings for this module -import worker from "@cloudflare/workers-shared/dist/asset-worker.mjs"; +import worker from "@cloudflare/workers-shared/asset-worker/src/index"; // Simply re-export the whole of the asset-worker so that it gets compiled into the Miniflare code base. // This allows us to have it as a devDependency only. diff --git a/packages/miniflare/src/workers/assets/router.worker.ts b/packages/miniflare/src/workers/assets/router.worker.ts index 082655ea8883..90b4199754f5 100644 --- a/packages/miniflare/src/workers/assets/router.worker.ts +++ b/packages/miniflare/src/workers/assets/router.worker.ts @@ -1,5 +1,4 @@ -// @ts-ignore the fact that there are no typings for this module -import worker from "@cloudflare/workers-shared/dist/router-worker.mjs"; +import worker from "@cloudflare/workers-shared/router-worker/src/index"; // Simply re-export the whole of the router-worker so that it gets compiled into the Miniflare code base. // This allows us to have it as a devDependency only. diff --git a/packages/workers-shared/asset-worker/src/assets-manifest.ts b/packages/workers-shared/asset-worker/src/assets-manifest.ts index d956821d708d..25eb8a797de4 100644 --- a/packages/workers-shared/asset-worker/src/assets-manifest.ts +++ b/packages/workers-shared/asset-worker/src/assets-manifest.ts @@ -1,13 +1,9 @@ -const HEADER_SIZE = 20; -const PATH_HASH_SIZE = 16; -const CONTENT_HASH_SIZE = 16; -const TAIL_SIZE = 8; -const ENTRY_SIZE = PATH_HASH_SIZE + CONTENT_HASH_SIZE + TAIL_SIZE; - -export type AssetEntry = { - path: string; - contentHash: string; -}; +import { + CONTENT_HASH_SIZE, + ENTRY_SIZE, + HEADER_SIZE, + PATH_HASH_SIZE, +} from "../../utils/constants"; export class AssetsManifest { private data: ArrayBuffer; @@ -89,9 +85,9 @@ const compare = (a: Uint8Array, b: Uint8Array) => { }; const contentHashToKey = (buffer: Uint8Array) => { - const contentHash = new Uint8Array( - buffer, - buffer.byteOffset + PATH_HASH_SIZE - ).slice(0, CONTENT_HASH_SIZE); + const contentHash = buffer.slice( + PATH_HASH_SIZE, + PATH_HASH_SIZE + CONTENT_HASH_SIZE + ); return [...contentHash].map((b) => b.toString(16).padStart(2, "0")).join(""); }; diff --git a/packages/workers-shared/asset-worker/src/configuration.ts b/packages/workers-shared/asset-worker/src/configuration.ts new file mode 100644 index 000000000000..0df3cc7962a9 --- /dev/null +++ b/packages/workers-shared/asset-worker/src/configuration.ts @@ -0,0 +1,10 @@ +import type { AssetConfig } from "../../utils/types"; + +export const applyConfigurationDefaults = ( + configuration?: AssetConfig +): Required => { + return { + html_handling: configuration?.html_handling ?? "auto-trailing-slash", + not_found_handling: configuration?.not_found_handling ?? "none", + }; +}; diff --git a/packages/workers-shared/asset-worker/src/global.d.ts b/packages/workers-shared/asset-worker/src/global.d.ts deleted file mode 100644 index 56e67727f369..000000000000 --- a/packages/workers-shared/asset-worker/src/global.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type Env = { - // ASSETS_MANIFEST is a pipeline binding to an ArrayBuffer containing the - // binary-encoded site manifest - ASSETS_MANIFEST: ArrayBuffer; - - // ASSETS_KV_NAMESPACE is a pipeline binding to the KV namespace that the - // assets are in. - ASSETS_KV_NAMESPACE: KVNamespace; -}; diff --git a/packages/workers-shared/asset-worker/src/handler.ts b/packages/workers-shared/asset-worker/src/handler.ts new file mode 100644 index 000000000000..1565ee47a8e4 --- /dev/null +++ b/packages/workers-shared/asset-worker/src/handler.ts @@ -0,0 +1,587 @@ +import { + InternalServerErrorResponse, + MethodNotAllowedResponse, + NotFoundResponse, + NotModifiedResponse, + OkResponse, + TemporaryRedirectResponse, +} from "./responses"; +import { getHeaders } from "./utils/headers"; +import type { AssetConfig } from "../../utils/types"; +import type EntrypointType from "./index"; + +export const handleRequest = async ( + request: Request, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + getByETag: typeof EntrypointType.prototype.getByETag +) => { + const { pathname, search } = new URL(request.url); + + const intent = await getIntent(pathname, configuration, exists); + if (!intent) { + return new NotFoundResponse(); + } + + // if there was a POST etc. to a route without an asset + // this should be passed onto a user worker if one exists + // so prioritise returning a 404 over 405? + const method = request.method.toUpperCase(); + if (!["GET", "HEAD"].includes(method)) { + return new MethodNotAllowedResponse(); + } + if (intent.redirect) { + return new TemporaryRedirectResponse(intent.redirect + search); + } + if (!intent.asset) { + return new InternalServerErrorResponse(new Error("Unknown action")); + } + + const asset = await getByETag(intent.asset.eTag); + + const headers = getHeaders(intent.asset.eTag, asset.contentType, request); + + const strongETag = `"${intent.asset.eTag}"`; + const weakETag = `W/${strongETag}`; + const ifNoneMatch = request.headers.get("If-None-Match") || ""; + if ([weakETag, strongETag].includes(ifNoneMatch)) { + return new NotModifiedResponse(null, { headers }); + } + + const body = method === "HEAD" ? null : asset.readableStream; + switch (intent.asset.status) { + case 404: + return new NotFoundResponse(body, { headers }); + case 200: + return new OkResponse(body, { headers }); + } +}; + +type Intent = + | { + asset: { eTag: string; status: 200 | 404 }; + redirect: null; + } + | { asset: null; redirect: string } + | null; + +export const getIntent = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + skipRedirects = false +): Promise => { + switch (configuration.html_handling) { + case "auto-trailing-slash": { + return htmlHandlingAutoTrailingSlash( + pathname, + configuration, + exists, + skipRedirects + ); + } + case "force-trailing-slash": { + return htmlHandlingForceTrailingSlash( + pathname, + configuration, + exists, + skipRedirects + ); + } + case "drop-trailing-slash": { + return htmlHandlingDropTrailingSlash( + pathname, + configuration, + exists, + skipRedirects + ); + } + case "none": { + return htmlHandlingNone(pathname, configuration, exists); + } + } +}; + +const htmlHandlingAutoTrailingSlash = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + skipRedirects: boolean +): Promise => { + let redirectResult: Intent = null; + let eTagResult: string | null = null; + const exactETag = await exists(pathname); + if (pathname.endsWith("/index")) { + if (exactETag) { + // there's a binary /index file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else { + if ( + (redirectResult = await safeRedirect( + `${pathname}.html`, + pathname.slice(0, -"index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index".length)}.html`, + pathname.slice(0, -"/index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } + } + } else if (pathname.endsWith("/index.html")) { + if ( + (redirectResult = await safeRedirect( + pathname, + pathname.slice(0, -"index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index.html".length)}.html`, + pathname.slice(0, -"/index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } + } else if (pathname.endsWith("/")) { + if ((eTagResult = await exists(`${pathname}index.html`))) { + // /foo/index.html exists so serve at /foo/ + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/".length)}.html`, + pathname.slice(0, -"/".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } + } else if (pathname.endsWith(".html")) { + if ( + (redirectResult = await safeRedirect( + pathname, + pathname.slice(0, -".html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -".html".length)}/index.html`, + `${pathname.slice(0, -".html".length)}/`, + configuration, + exists, + skipRedirects + )) + ) { + // request for /foo.html but /foo/index.html exists so redirect to /foo/ + return redirectResult; + } + } + + if (exactETag) { + // there's a binary /foo file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else if ((eTagResult = await exists(`${pathname}.html`))) { + // foo.html exists so serve at /foo + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } else if ( + (redirectResult = await safeRedirect( + `${pathname}/index.html`, + `${pathname}/`, + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } + + return notFound(pathname, configuration, exists); +}; + +const htmlHandlingForceTrailingSlash = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + skipRedirects: boolean +): Promise => { + let redirectResult: Intent = null; + let eTagResult: string | null = null; + const exactETag = await exists(pathname); + if (pathname.endsWith("/index")) { + if (exactETag) { + // there's a binary /index file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else { + if ( + (redirectResult = await safeRedirect( + `${pathname}.html`, + pathname.slice(0, -"index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index".length)}.html`, + pathname.slice(0, -"index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo/ + return redirectResult; + } + } + } else if (pathname.endsWith("/index.html")) { + if ( + (redirectResult = await safeRedirect( + pathname, + pathname.slice(0, -"index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index.html".length)}.html`, + pathname.slice(0, -"index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo/ + return redirectResult; + } + } else if (pathname.endsWith("/")) { + if ((eTagResult = await exists(`${pathname}index.html`))) { + // /foo/index.html exists so serve at /foo/ + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } else if ( + (eTagResult = await exists(`${pathname.slice(0, -"/".length)}.html`)) + ) { + // /foo.html exists so serve at /foo/ + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } + } else if (pathname.endsWith(".html")) { + if ( + (redirectResult = await safeRedirect( + pathname, + `${pathname.slice(0, -".html".length)}/`, + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo/ + return redirectResult; + } else if (exactETag) { + // there's both /foo.html and /foo/index.html so we serve /foo.html at /foo.html only + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -".html".length)}/index.html`, + `${pathname.slice(0, -".html".length)}/`, + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } + } + + if (exactETag) { + // there's a binary /foo file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else if ( + (redirectResult = await safeRedirect( + `${pathname}.html`, + `${pathname}/`, + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo/ + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname}/index.html`, + `${pathname}/`, + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo/ + return redirectResult; + } + + return notFound(pathname, configuration, exists); +}; + +const htmlHandlingDropTrailingSlash = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + skipRedirects: boolean +): Promise => { + let redirectResult: Intent = null; + let eTagResult: string | null = null; + const exactETag = await exists(pathname); + if (pathname.endsWith("/index")) { + if (exactETag) { + // there's a binary /index file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else { + if (pathname === "/index") { + if ( + (redirectResult = await safeRedirect( + "/index.html", + "/", + configuration, + exists, + skipRedirects + )) + ) { + return redirectResult; + } + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index".length)}.html`, + pathname.slice(0, -"/index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname}.html`, + pathname.slice(0, -"/index".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo + return redirectResult; + } + } + } else if (pathname.endsWith("/index.html")) { + // special handling so you don't drop / if the path is just / + if (pathname === "/index.html") { + if ( + (redirectResult = await safeRedirect( + "/index.html", + "/", + configuration, + exists, + skipRedirects + )) + ) { + return redirectResult; + } + } else if ( + (redirectResult = await safeRedirect( + pathname, + pathname.slice(0, -"/index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo + return redirectResult; + } else if (exactETag) { + // there's both /foo.html and /foo/index.html so we serve /foo/index.html at /foo/index.html only + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/index.html".length)}.html`, + pathname.slice(0, -"/index.html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } + } else if (pathname.endsWith("/")) { + if (pathname === "/") { + if ((eTagResult = await exists("/index.html"))) { + // /index.html exists so serve at / + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/".length)}.html`, + pathname.slice(0, -"/".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -"/".length)}/index.html`, + pathname.slice(0, -"/".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo + return redirectResult; + } + } else if (pathname.endsWith(".html")) { + if ( + (redirectResult = await safeRedirect( + pathname, + pathname.slice(0, -".html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo.html exists so redirect to /foo + return redirectResult; + } else if ( + (redirectResult = await safeRedirect( + `${pathname.slice(0, -".html".length)}/index.html`, + pathname.slice(0, -".html".length), + configuration, + exists, + skipRedirects + )) + ) { + // /foo/index.html exists so redirect to /foo + return redirectResult; + } + } + + if (exactETag) { + // there's a binary /foo file + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else if ((eTagResult = await exists(`${pathname}.html`))) { + // /foo.html exists so serve at /foo + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } else if ((eTagResult = await exists(`${pathname}/index.html`))) { + // /foo/index.html exists so serve at /foo + return { asset: { eTag: eTagResult, status: 200 }, redirect: null }; + } + + return notFound(pathname, configuration, exists); +}; + +const htmlHandlingNone = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists +): Promise => { + const exactETag = await exists(pathname); + if (exactETag) { + return { asset: { eTag: exactETag, status: 200 }, redirect: null }; + } else { + return notFound(pathname, configuration, exists); + } +}; + +const notFound = async ( + pathname: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists +): Promise => { + switch (configuration.not_found_handling) { + case "single-page-application": { + const eTag = await exists("/index.html"); + if (eTag) { + return { asset: { eTag, status: 200 }, redirect: null }; + } + return null; + } + case "404-page": { + let cwd = pathname; + while (cwd) { + cwd = cwd.slice(0, cwd.lastIndexOf("/")); + const eTag = await exists(`${cwd}/404.html`); + if (eTag) { + return { asset: { eTag, status: 404 }, redirect: null }; + } + } + return null; + } + case "none": + default: { + return null; + } + } +}; + +const safeRedirect = async ( + file: string, + destination: string, + configuration: Required, + exists: typeof EntrypointType.prototype.exists, + skip: boolean +): Promise => { + if (skip) { + return null; + } + + if (!(await exists(destination))) { + const intent = await getIntent(destination, configuration, exists, true); + if (intent?.asset && intent.asset.eTag === (await exists(file))) { + return { + asset: null, + redirect: destination, + }; + } + } + + return null; +}; diff --git a/packages/workers-shared/asset-worker/src/index.ts b/packages/workers-shared/asset-worker/src/index.ts index 57a4879b00a7..39fbeb39bcad 100644 --- a/packages/workers-shared/asset-worker/src/index.ts +++ b/packages/workers-shared/asset-worker/src/index.ts @@ -1,56 +1,94 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { AssetsManifest } from "./assets-manifest"; +import { applyConfigurationDefaults } from "./configuration"; +import { getIntent, handleRequest } from "./handler"; import { InternalServerErrorResponse, MethodNotAllowedResponse, - NotFoundResponse, - OkResponse, } from "./responses"; -import { getAdditionalHeaders, getMergedHeaders } from "./utils/headers"; import { getAssetWithMetadataFromKV } from "./utils/kv"; +import type { AssetConfig } from "../../utils/types"; + +type Env = { + // ASSETS_MANIFEST is a pipeline binding to an ArrayBuffer containing the + // binary-encoded site manifest + ASSETS_MANIFEST: ArrayBuffer; + + // ASSETS_KV_NAMESPACE is a pipeline binding to the KV namespace that the + // assets are in. + ASSETS_KV_NAMESPACE: KVNamespace; + + CONFIG: AssetConfig; +}; export default class extends WorkerEntrypoint { - async fetch(request: Request) { + async fetch(request: Request): Promise { try { - return this.handleRequest(request); + return handleRequest( + request, + applyConfigurationDefaults(this.env.CONFIG), + this.exists.bind(this), + this.getByETag.bind(this) + ); } catch (err) { - return new InternalServerErrorResponse(err); + return new InternalServerErrorResponse(err as Error); } } - async handleRequest(request: Request) { - const assetEntry = await this.getAssetEntry(request); - if (!assetEntry) { - return new NotFoundResponse(); - } - if (request.method.toLowerCase() !== "get") { + async unstable_canFetch(request: Request): Promise { + const url = new URL(request.url); + const method = request.method.toUpperCase(); + const intent = await getIntent( + url.pathname, + { + ...applyConfigurationDefaults(this.env.CONFIG), + not_found_handling: "none", + }, + this.exists.bind(this) + ); + // if asset exists but non GET/HEAD method, 405 + if (intent && ["GET", "HEAD"].includes(method)) { return new MethodNotAllowedResponse(); } - const assetResponse = await getAssetWithMetadataFromKV( + if (intent === null) { + return false; + } + return true; + } + + async getByETag( + eTag: string + ): Promise<{ readableStream: ReadableStream; contentType: string }> { + const asset = await getAssetWithMetadataFromKV( this.env.ASSETS_KV_NAMESPACE, - assetEntry + eTag ); - if (!assetResponse || !assetResponse.value) { + if (!asset || !asset.value) { throw new Error( - `Requested asset ${assetEntry} exists in the asset manifest but not in the KV namespace.` + `Requested asset ${eTag} exists in the asset manifest but not in the KV namespace.` ); } - const { value: assetContent, metadata: assetMetadata } = assetResponse; - const additionalHeaders = getAdditionalHeaders( - assetEntry, - assetMetadata, - request - ); - const headers = getMergedHeaders(request.headers, additionalHeaders); + return { + readableStream: asset.value, + contentType: asset.metadata?.contentType ?? "application/octet-stream", + }; + } - return new OkResponse(assetContent, { headers }); + async getByPathname( + pathname: string + ): Promise<{ readableStream: ReadableStream; contentType: string } | null> { + const eTag = await this.exists(pathname); + if (!eTag) { + return null; + } + + return this.getByETag(eTag); } - private async getAssetEntry(request: Request) { - const url = new URL(request.url); + async exists(pathname: string): Promise { const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST); - return await assetsManifest.get(url.pathname); + return await assetsManifest.get(pathname); } } diff --git a/packages/workers-shared/asset-worker/src/responses.ts b/packages/workers-shared/asset-worker/src/responses.ts index 7db1ff993014..1a9a83270dc8 100644 --- a/packages/workers-shared/asset-worker/src/responses.ts +++ b/packages/workers-shared/asset-worker/src/responses.ts @@ -29,7 +29,7 @@ export class MethodNotAllowedResponse extends Response { export class InternalServerErrorResponse extends Response { constructor(err: Error, init?: ResponseInit) { - super(undefined, { + super(null, { ...init, status: 500, }); @@ -37,10 +37,25 @@ export class InternalServerErrorResponse extends Response { } export class NotModifiedResponse extends Response { - constructor(...[_body, _init]: ConstructorParameters) { - super(undefined, { + constructor(...[_body, init]: ConstructorParameters) { + super(null, { + ...init, status: 304, statusText: "Not Modified", }); } } + +export class TemporaryRedirectResponse extends Response { + constructor(location: string, init?: ResponseInit) { + super(null, { + ...init, + status: 307, + statusText: "Temporary Redirect", + headers: { + ...init?.headers, + Location: location, + }, + }); + } +} diff --git a/packages/workers-shared/asset-worker/src/utils/headers.ts b/packages/workers-shared/asset-worker/src/utils/headers.ts index a34c659e9577..c3b84d381090 100644 --- a/packages/workers-shared/asset-worker/src/utils/headers.ts +++ b/packages/workers-shared/asset-worker/src/utils/headers.ts @@ -1,24 +1,4 @@ import { CACHE_CONTROL_BROWSER } from "../constants"; -import type { AssetMetadata } from "./kv"; - -/** - * Returns a Headers object that is the union of `existingHeaders` - * and `additionalHeaders`. Headers specified by `additionalHeaders` - * will override those specified by `existingHeaders`. - * - */ -export function getMergedHeaders( - existingHeaders: Headers, - additionalHeaders: Headers -) { - const mergedHeaders = new Headers(existingHeaders); - for (const [key, value] of additionalHeaders) { - // override existing headers - mergedHeaders.set(key, value); - } - - return mergedHeaders; -} /** * Returns a Headers object that contains additional headers (to those @@ -26,22 +6,14 @@ export function getMergedHeaders( * should attach to its response. * */ -export function getAdditionalHeaders( - assetKey: string, - assetMetadata: AssetMetadata | null, +export function getHeaders( + eTag: string, + contentType: string, request: Request ) { - let contentType = assetMetadata?.contentType ?? "application/octet-stream"; - if (contentType.startsWith("text/") && !contentType.includes("charset")) { - contentType = `${contentType}; charset=utf-8`; - } - const headers = new Headers({ - "Access-Control-Allow-Origin": "*", "Content-Type": contentType, - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - ETag: `${assetKey}`, + ETag: `"${eTag}"`, }); if (isCacheable(request)) { @@ -52,5 +24,5 @@ export function getAdditionalHeaders( } function isCacheable(request: Request) { - return !request.headers.has("authorization") && !request.headers.has("range"); + return !request.headers.has("Authorization") && !request.headers.has("Range"); } diff --git a/packages/workers-shared/asset-worker/tests/headers.test.ts b/packages/workers-shared/asset-worker/tests/headers.test.ts deleted file mode 100644 index 282f8fba77be..000000000000 --- a/packages/workers-shared/asset-worker/tests/headers.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { getAdditionalHeaders, getMergedHeaders } from "../src/utils/headers"; -import type { AssetMetadata } from "../src/utils/kv"; - -describe("[Asset Worker] Response Headers", () => { - describe("getMergedHeaders()", () => { - it("should merge headers with override", () => { - const existingHeaders = new Headers({ - "Accept-Encoding": "gzip", - "Cache-Control": "max-age=180, public", - "Content-Type": "text/html; charset=utf-8", - }); - - const additionalHeaders = new Headers({ - "Accept-Encoding": "*", - "Content-Type": "text/javascript; charset=utf-8", - "Keep-Alive": "timeout=5, max=1000", - }); - - const mergedHeaders = getMergedHeaders( - existingHeaders, - additionalHeaders - ); - expect(mergedHeaders).toEqual( - new Headers({ - "Accept-Encoding": "*", - "Cache-Control": "max-age=180, public", - "Content-Type": "text/javascript; charset=utf-8", - "Keep-Alive": "timeout=5, max=1000", - }) - ); - }); - }); - - describe("getAdditionalHeaders()", () => { - it("should return the default headers the Asset Worker should set on every response", () => { - const request = new Request("https://example.com", { - method: "GET", - headers: { - "Accept-Encoding": "*", - }, - }); - const assetMetadata: AssetMetadata = { - contentType: "text/html; charset=utf-8", - }; - const additionalHeaders = getAdditionalHeaders( - "33a64df551425fcc55e4d42a148795d9f25f89d4", - assetMetadata, - request - ); - - expect(additionalHeaders).toEqual( - new Headers({ - "Access-Control-Allow-Origin": "*", - "Content-Type": "text/html; charset=utf-8", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4", - "Cache-Control": "public, max-age=0, must-revalidate", - }) - ); - }); - - it("should default 'Content-Type' to 'application/octet-stream' if not specified by asset metadata", () => { - const request = new Request("https://example.com", { - method: "GET", - headers: { - "Accept-Encoding": "*", - }, - }); - const additionalHeaders = getAdditionalHeaders( - "33a64df551425fcc55e4d42a148795d9f25f89d4", - null, - request - ); - - expect(additionalHeaders).toEqual( - new Headers({ - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/octet-stream", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4", - "Cache-Control": "public, max-age=0, must-revalidate", - }) - ); - }); - - it("should set the 'charset' to 'utf-8' when appropriate, if not specified", () => { - const request = new Request("https://example.com", { - method: "GET", - headers: { - "Accept-Encoding": "*", - }, - }); - const assetMetadata: AssetMetadata = { contentType: "text/html" }; - const additionalHeaders = getAdditionalHeaders( - "33a64df551425fcc55e4d42a148795d9f25f89d4", - assetMetadata, - request - ); - - expect(additionalHeaders).toEqual( - new Headers({ - "Access-Control-Allow-Origin": "*", - "Content-Type": "text/html; charset=utf-8", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4", - "Cache-Control": "public, max-age=0, must-revalidate", - }) - ); - }); - - it("should not set the 'Cache-Control' header, if 'Authorization' and 'Range' headers are present in the request", () => { - const request = new Request("https://example.com", { - method: "GET", - headers: { - "Accept-Encoding": "*", - Authorization: "Basic 123", - Range: "bytes=0-499", - }, - }); - const assetMetadata: AssetMetadata = { - contentType: "text/html; charset=utf-8", - }; - const additionalHeaders = getAdditionalHeaders( - "33a64df551425fcc55e4d42a148795d9f25f89d4", - assetMetadata, - request - ); - - expect(additionalHeaders).toEqual( - new Headers({ - "Access-Control-Allow-Origin": "*", - "Content-Type": "text/html; charset=utf-8", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4", - }) - ); - }); - }); -}); diff --git a/packages/workers-shared/asset-worker/wrangler.toml b/packages/workers-shared/asset-worker/wrangler.toml index e37545088812..10e32f743dee 100644 --- a/packages/workers-shared/asset-worker/wrangler.toml +++ b/packages/workers-shared/asset-worker/wrangler.toml @@ -12,18 +12,4 @@ account_id = "0f1b8aa119a907021f659042f95ea9ba" workers_dev = false main = "src/index.ts" compatibility_date = "2024-07-31" -compatibility_flags = ["nodejs_compat"] - -[[unsafe.bindings]] -name = "ASSETS_MANIFEST" -type = "param" -param = "assetManifest" -data_ref = true - -[[unsafe.bindings]] -name = "ASSETS_KV_NAMESPACE" -type = "internal_assets" - -[unsafe.metadata.build_options] -stable_id = "cloudflare/cf_asset_worker" -networks = ["cf","jdc"] \ No newline at end of file +compatibility_flags = ["nodejs_compat"] \ No newline at end of file diff --git a/packages/workers-shared/index.ts b/packages/workers-shared/index.ts new file mode 100644 index 000000000000..9297a281292d --- /dev/null +++ b/packages/workers-shared/index.ts @@ -0,0 +1,3 @@ +export * from "./utils/constants"; +export * from "./utils/types"; +export * from "./utils/helpers"; diff --git a/packages/workers-shared/package.json b/packages/workers-shared/package.json index 9f6ddfa67bef..ac5dacbd6301 100644 --- a/packages/workers-shared/package.json +++ b/packages/workers-shared/package.json @@ -18,11 +18,12 @@ }, "license": "MIT OR Apache-2.0", "author": "wrangler@cloudflare.com", + "types": "./dist", "files": [ "dist" ], "scripts": { - "build": "pnpm run clean && pnpm run bundle:asset-worker:prod && pnpm run bundle:router-worker:prod", + "build": "pnpm run clean && pnpm run bundle:asset-worker:prod && pnpm run bundle:router-worker:prod && pnpm run types:emit", "bundle:asset-worker": "esbuild asset-worker/src/index.ts --format=esm --bundle --outfile=dist/asset-worker.mjs --sourcemap=external --external:cloudflare:*", "bundle:asset-worker:prod": "pnpm run bundle:asset-worker --minify && node -r esbuild-register scripts/copy-config-file.ts", "bundle:router-worker": "esbuild router-worker/src/index.ts --format=esm --bundle --outfile=dist/router-worker.mjs --sourcemap=external", @@ -36,12 +37,18 @@ "deploy:router-worker": "CLOUDFLARE_API_TOKEN=$WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN wrangler versions upload --experimental-versions -c router-worker/wrangler.toml", "dev": "pnpm run clean && concurrently -n bundle:asset-worker,bundle:router-worker -c blue,magenta \"pnpm run bundle:asset-worker --watch\" \"pnpm run bundle:router-worker --watch\"", "test": "vitest", - "test:ci": "pnpm run test run" + "test:ci": "pnpm run test", + "types:emit": "tsc index.ts --declaration --emitDeclarationOnly --declarationDir ./dist" + }, + "dependencies": { + "mime": "^3.0.0", + "zod": "^3.22.3" }, "devDependencies": { "@cloudflare/eslint-config-worker": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "^4.20240909.0", + "@types/mime": "^3.0.4", "concurrently": "^8.2.2", "esbuild": "0.17.19", "rimraf": "^6.0.1", diff --git a/packages/workers-shared/router-worker/src/index.ts b/packages/workers-shared/router-worker/src/index.ts index ae6549173e37..4ad111b9e750 100644 --- a/packages/workers-shared/router-worker/src/index.ts +++ b/packages/workers-shared/router-worker/src/index.ts @@ -1,20 +1,23 @@ +import type AssetWorker from "../../asset-worker/src/index"; import type { RoutingConfig } from "../../utils/types"; interface Env { - ASSET_WORKER: Fetcher; + ASSET_WORKER: Service; USER_WORKER: Fetcher; CONFIG: RoutingConfig; } export default { async fetch(request: Request, env: Env) { - const maybeUserRequest = request.clone(); - const result = await env.ASSET_WORKER.fetch(request); - // 404 = asset not in manifest - if (result.status === 404 && env.CONFIG.hasUserWorker) { - return await env.USER_WORKER.fetch(maybeUserRequest); + const maybeSecondRequest = request.clone(); + if (env.CONFIG.has_user_worker) { + if (await env.ASSET_WORKER.unstable_canFetch(request)) { + return await env.ASSET_WORKER.fetch(maybeSecondRequest); + } else { + return env.USER_WORKER.fetch(maybeSecondRequest); + } } - // all other responses from AW are simply returned - return result; + + return await env.ASSET_WORKER.fetch(request); }, }; diff --git a/packages/workers-shared/router-worker/wrangler.toml b/packages/workers-shared/router-worker/wrangler.toml index b9f12286f980..9b1ec5ed63c8 100644 --- a/packages/workers-shared/router-worker/wrangler.toml +++ b/packages/workers-shared/router-worker/wrangler.toml @@ -8,20 +8,5 @@ # (see packages/wrangler/src/dev/miniflare.ts -> buildMiniflareOptions()) ## name = "router-worker" -account_id = "0f1b8aa119a907021f659042f95ea9ba" -workers_dev = false main = "src/index.ts" -compatibility_date = "2024-07-31" - -[[unsafe.bindings]] -name = "ASSET_WORKER" -type = "internal_assets" -fetcherApi = "fetcher" - -[[unsafe.bindings]] -name = "USER_WORKER" -type = "origin" - -[unsafe.metadata.build_options] -stable_id = "cloudflare/cf_router_worker" -networks = ["cf","jdc"] +compatibility_date = "2024-07-31" \ No newline at end of file diff --git a/packages/workers-shared/turbo.json b/packages/workers-shared/turbo.json index 1a6c74c9def8..a0be18d53bab 100644 --- a/packages/workers-shared/turbo.json +++ b/packages/workers-shared/turbo.json @@ -3,6 +3,14 @@ "extends": ["//"], "pipeline": { "build": { + "inputs": [ + "asset-worker/**", + "router-worker/**", + "utils/**", + "*.js", + "*.ts", + "*.json" + ], "outputs": ["dist/**"] } } diff --git a/packages/workers-shared/utils/constants.ts b/packages/workers-shared/utils/constants.ts new file mode 100644 index 000000000000..3b351b0530b6 --- /dev/null +++ b/packages/workers-shared/utils/constants.ts @@ -0,0 +1,29 @@ +// -- Constants for manifest encoding/decoding -- +// (header and tails are not currently being used for anything afaik) +// NB these all refer to bytes + +/** Reserved header at the start of the whole manifest, NOT in each entry (currently unused) + * manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] + */ +export const HEADER_SIZE = 20; +/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const PATH_HASH_SIZE = 16; +/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const CONTENT_HASH_SIZE = 16; +/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const TAIL_SIZE = 8; +/** offset of PATH_HASH from start of each entry + * manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const PATH_HASH_OFFSET = 0; +/** offset of CONTENT_HASH from start of each entry + * manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const CONTENT_HASH_OFFSET = PATH_HASH_SIZE; +/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */ +export const ENTRY_SIZE = PATH_HASH_SIZE + CONTENT_HASH_SIZE + TAIL_SIZE; + +// -- Manifest creation constants -- +// used in wrangler dev and deploy +/** Maximum number of assets that can be deployed with a worker */ +export const MAX_ASSET_COUNT = 20_000; +/** Maximum size per asset that can be deployed with a worker */ +export const MAX_ASSET_SIZE = 25 * 1024 * 1024; diff --git a/packages/workers-shared/utils/helpers.ts b/packages/workers-shared/utils/helpers.ts new file mode 100644 index 000000000000..7bd560658dc5 --- /dev/null +++ b/packages/workers-shared/utils/helpers.ts @@ -0,0 +1,26 @@ +import { getType } from "mime"; + +/** normalises sep for windows, and encodes each segment */ +export const encodeFilePath = (filePath: string, sep: string) => { + const encodedPath = filePath + .split(sep) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return "/" + encodedPath; +}; + +/** reverses encodeFilePath for accessing from file system */ +export const decodeFilePath = (filePath: string, sep: string) => { + return filePath + .split("/") + .map((segment) => decodeURIComponent(segment)) + .join(sep); +}; + +export const getContentType = (absFilePath: string) => { + let contentType = getType(absFilePath) || "application/octet-stream"; + if (contentType.startsWith("text/") && !contentType.includes("charset")) { + contentType = `${contentType}; charset=utf-8`; + } + return contentType; +}; diff --git a/packages/workers-shared/utils/types.ts b/packages/workers-shared/utils/types.ts index de04a473e832..cd4bf5196979 100644 --- a/packages/workers-shared/utils/types.ts +++ b/packages/workers-shared/utils/types.ts @@ -1,10 +1,22 @@ -// import { z } from "zod"; +import { z } from "zod"; -// export const RoutingConfigSchema = z.object({ -// hasUserWorker: z.boolean(), -// }); +export const RoutingConfigSchema = z.object({ + has_user_worker: z.boolean().optional(), +}); -// export type RoutingConfig = z.infer; -export type RoutingConfig = { - hasUserWorker: boolean; -}; +export const AssetConfigSchema = z.object({ + html_handling: z + .enum([ + "auto-trailing-slash", + "force-trailing-slash", + "drop-trailing-slash", + "none", + ]) + .optional(), + not_found_handling: z + .enum(["single-page-application", "404-page", "none"]) + .optional(), +}); + +export type RoutingConfig = z.infer; +export type AssetConfig = z.infer; diff --git a/packages/wrangler/scripts/deps.ts b/packages/wrangler/scripts/deps.ts index 874df64b2740..4c6ed1409263 100644 --- a/packages/wrangler/scripts/deps.ts +++ b/packages/wrangler/scripts/deps.ts @@ -22,7 +22,6 @@ export const EXTERNAL_DEPENDENCIES = [ // and read when we are bundling the worker application "unenv", "workerd/worker.mjs", - "@cloudflare/workers-shared", ]; const pathToPackageJson = path.resolve(__dirname, "..", "package.json"); diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index c55200e843b3..d9a29e0ecbf1 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -1825,6 +1825,54 @@ describe("normalizeAndValidateConfig()", () => { `); }); + it("should error on invalid `experimental_assets` config values", () => { + const expectedConfig = { + experimental_assets: { + directory: "./public", + html_handling: "foo", + not_found_handling: "bar", + }, + }; + + const { config, diagnostics } = normalizeAndValidateConfig( + expectedConfig as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(config).toEqual(expect.objectContaining(expectedConfig)); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + " + `); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Expected \\"experimental_assets.html_handling\\" field to be one of [\\"auto-trailing-slash\\",\\"force-trailing-slash\\",\\"drop-trailing-slash\\",\\"none\\"] but got \\"foo\\". + - Expected \\"experimental_assets.not_found_handling\\" field to be one of [\\"single-page-application\\",\\"404-page\\",\\"none\\"] but got \\"bar\\"." + `); + }); + + it("should accept valid `experimental_assets` config values", () => { + const expectedConfig: RawConfig = { + experimental_assets: { + directory: "./public", + html_handling: "drop-trailing-slash", + not_found_handling: "404-page", + }, + }; + + const { config, diagnostics } = normalizeAndValidateConfig( + expectedConfig, + undefined, + { env: undefined } + ); + + expect(config).toEqual(expect.objectContaining(expectedConfig)); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.hasErrors()).toBe(false); + }); + it("should error if `directory` is an empty string", () => { const expectedConfig = { experimental_assets: { diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 82b8b03a5e89..e02215adc8a2 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -4416,6 +4416,7 @@ addEventListener('fetch', event => {});` ["Q29udGVudCBvZiBmaWxlLTM="], "ff5016e92f039aa743a4ff7abb3180fa", { + // TODO: this should be "text/plain; charset=utf-8", but msw? is stripping the charset part type: "text/plain", } ) @@ -4682,6 +4683,7 @@ addEventListener('fetch', event => {});` ["Q29udGVudCBvZiBmaWxlLTI="], "7574a8cd3094a050388ac9663af1c1d6", { + // TODO: this should be "text/plain; charset=utf-8", but msw? is stripping the charset part type: "text/plain", } ) diff --git a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts index 47a53f4b9c20..285149c94a37 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-upload-worker.ts @@ -106,7 +106,10 @@ export function mockUploadWorkerRequest( expect(metadata.limits).toEqual(expectedLimits); } if ("expectedExperimentalAssets" in options) { - expect(metadata.assets).toEqual("<>"); + expect(metadata.assets).toEqual({ + jwt: "<>", + config: {}, + }); } if (expectedUnsafeMetaData !== undefined) { Object.keys(expectedUnsafeMetaData).forEach((key) => { diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index 73d36d468437..207fe7b384f2 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -897,4 +897,10 @@ export type ExperimentalAssets = { /** Absolute path to assets directory */ directory: string; binding?: string; + html_handling?: + | "auto-trailing-slash" + | "force-trailing-slash" + | "drop-trailing-slash" + | "none"; + not_found_handling?: "single-page-application" | "404-page" | "none"; }; diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index ccb385d49c1b..cbde5be7f3b3 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -2102,10 +2102,37 @@ const validateAssetsConfig: ValidatorFn = (diagnostics, field, value) => { "string" ) && isValid; + isValid = + validateOptionalProperty( + diagnostics, + field, + "html_handling", + (value as ExperimentalAssets).html_handling, + "string", + [ + "auto-trailing-slash", + "force-trailing-slash", + "drop-trailing-slash", + "none", + ] + ) && isValid; + + isValid = + validateOptionalProperty( + diagnostics, + field, + "not_found_handling", + (value as ExperimentalAssets).not_found_handling, + "string", + ["single-page-application", "404-page", "none"] + ) && isValid; + isValid = validateAdditionalProperties(diagnostics, field, Object.keys(value), [ "directory", "binding", + "html_handling", + "not_found_handling", ]) && isValid; return isValid; diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index dd1013bc8ef8..4deed1ca9502 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -635,16 +635,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m : undefined; // Upload assets if experimental assets is being used - const experimentalAssetsOptions = + const experimentalAssetsJwt = props.experimentalAssetsOptions && !props.dryRun - ? { - routingConfig: props.experimentalAssetsOptions?.routingConfig, - jwt: await syncExperimentalAssets( - accountId, - scriptName, - props.experimentalAssetsOptions.directory - ), - } + ? await syncExperimentalAssets( + accountId, + scriptName, + props.experimentalAssetsOptions.directory + ) : undefined; const legacyAssets = await syncLegacyAssets( @@ -743,7 +740,14 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m placement, tail_consumers: config.tail_consumers, limits: config.limits, - experimental_assets: experimentalAssetsOptions, + experimental_assets: + props.experimentalAssetsOptions && experimentalAssetsJwt + ? { + jwt: experimentalAssetsJwt, + routingConfig: props.experimentalAssetsOptions.routingConfig, + assetConfig: props.experimentalAssetsOptions.assetConfig, + } + : undefined, }; sourceMapSize = worker.sourceMaps?.reduce( diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index c3a48dc42f37..dc8c4a8fe9af 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -11,6 +11,7 @@ import type { CfUserLimits, CfWorkerInit, } from "./worker.js"; +import type { AssetConfig } from "@cloudflare/workers-shared"; import type { Json } from "miniflare"; const moduleTypeMimeType: { [type in CfModuleType]: string | undefined } = { @@ -140,7 +141,11 @@ export type WorkerMetadataPut = { tail_consumers?: CfTailConsumer[]; limits?: CfUserLimits; // experimental assets (EWC will expect 'assets') - assets?: string; + assets?: { + jwt: string; + config?: AssetConfig; + }; + // Allow unsafe.metadata to add arbitrary properties at runtime [key: string]: unknown; }; @@ -176,11 +181,24 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { experimental_assets, } = worker; + const assetConfig = { + html_handling: experimental_assets?.assetConfig?.html_handling, + not_found_handling: experimental_assets?.assetConfig?.not_found_handling, + }; + // short circuit if static assets upload only - if (experimental_assets && !experimental_assets.routingConfig.hasUserWorker) { + if ( + experimental_assets && + !experimental_assets.routingConfig.has_user_worker + ) { formData.set( "metadata", - JSON.stringify({ assets: experimental_assets.jwt }) + JSON.stringify({ + assets: { + jwt: experimental_assets.jwt, + config: assetConfig, + }, + }) ); return formData; } @@ -565,7 +583,12 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { ...(tail_consumers && { tail_consumers }), ...(limits && { limits }), ...(annotations && { annotations }), - ...(experimental_assets && { assets: experimental_assets.jwt }), + ...(experimental_assets && { + assets: { + jwt: experimental_assets.jwt, + config: assetConfig, + }, + }), }; if (bindings.unsafe?.metadata !== undefined) { diff --git a/packages/wrangler/src/deployment-bundle/worker.ts b/packages/wrangler/src/deployment-bundle/worker.ts index fc8679847c7b..29db9d36068e 100644 --- a/packages/wrangler/src/deployment-bundle/worker.ts +++ b/packages/wrangler/src/deployment-bundle/worker.ts @@ -1,9 +1,9 @@ import type { Route } from "../config/environment"; -import type { RoutingConfig } from "../experimental-assets"; import type { WorkerMetadata, WorkerMetadataBinding, } from "./create-worker-upload-form"; +import type { AssetConfig, RoutingConfig } from "@cloudflare/workers-shared"; import type { Json } from "miniflare"; /** @@ -287,6 +287,7 @@ export interface CfUserLimits { export interface CfExperimentalAssets { jwt: string; routingConfig: RoutingConfig; + assetConfig?: AssetConfig; } /** * Options for creating a `CfWorker`. diff --git a/packages/wrangler/src/dev/miniflare.ts b/packages/wrangler/src/dev/miniflare.ts index 8938cd15bdfd..1e4feb173b36 100644 --- a/packages/wrangler/src/dev/miniflare.ts +++ b/packages/wrangler/src/dev/miniflare.ts @@ -685,6 +685,7 @@ function buildAssetOptions(config: Omit) { path: config.experimentalAssets.directory, bindingName: config.experimentalAssets.binding, routingConfig: config.experimentalAssets.routingConfig, + assetConfig: config.experimentalAssets.assetConfig, }, }; } diff --git a/packages/wrangler/src/experimental-assets.ts b/packages/wrangler/src/experimental-assets.ts index e1adbed991fd..c821535e3b69 100644 --- a/packages/wrangler/src/experimental-assets.ts +++ b/packages/wrangler/src/experimental-assets.ts @@ -2,8 +2,14 @@ import assert from "node:assert"; import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import * as path from "node:path"; +import { + decodeFilePath, + encodeFilePath, + getContentType, + MAX_ASSET_COUNT, + MAX_ASSET_SIZE, +} from "@cloudflare/workers-shared"; import chalk from "chalk"; -import { getType } from "mime"; import PQueue from "p-queue"; import prettyBytes from "pretty-bytes"; import { File, FormData } from "undici"; @@ -17,6 +23,7 @@ import { APIError } from "./parse"; import { createPatternMatcher } from "./utils/filesystem"; import type { Config } from "./config"; import type { ExperimentalAssets } from "./config/environment"; +import type { AssetConfig, RoutingConfig } from "@cloudflare/workers-shared"; export type AssetManifest = { [path: string]: { hash: string; size: number } }; @@ -34,9 +41,6 @@ type UploadResponse = { const BULK_UPLOAD_CONCURRENCY = 3; const MAX_UPLOAD_ATTEMPTS = 5; const MAX_UPLOAD_GATEWAY_ERRORS = 5; -// NB also used in miniflare in plugins/kv/assets.ts, so please update there too. -const MAX_ASSET_COUNT = 20_000; -const MAX_ASSET_SIZE = 25 * 1024 * 1024; export const syncExperimentalAssets = async ( accountId: string | undefined, @@ -96,7 +100,7 @@ export const syncExperimentalAssets = async ( // just logging file uploads at the moment... // unsure how to log deletion vs unchanged file ignored/if we want to log this assetLogCount = logAssetUpload( - `+ ${decodeFilepath(manifestEntry[0])}`, + `+ ${decodeFilePath(manifestEntry[0], path.sep)}`, assetLogCount ); return manifestEntry; @@ -117,7 +121,7 @@ export const syncExperimentalAssets = async ( // This is so we don't run out of memory trying to upload the files. const payload = new FormData(); for (const manifestEntry of bucket) { - const decodedFilePath = decodeFilepath(manifestEntry[0]); + const decodedFilePath = decodeFilePath(manifestEntry[0], path.sep); const absFilePath = path.join(assetDirectory, decodedFilePath); payload.append( manifestEntry[1].hash, @@ -125,7 +129,7 @@ export const syncExperimentalAssets = async ( [(await readFile(absFilePath)).toString("base64")], manifestEntry[1].hash, { - type: getType(absFilePath) || "application/octet-stream", + type: getContentType(absFilePath), } ), manifestEntry[1].hash @@ -262,7 +266,7 @@ export const buildAssetsManifest = async (dir: string) => { `Ensure all assets in your assets directory "${dir}" conform with the Workers maximum size requirement.` ); } - manifest[encodeFilePath(relativeFilepath)] = { + manifest[encodeFilePath(relativeFilepath, path.sep)] = { hash: hashFile(filepath), size: filestat.size, }; @@ -306,12 +310,13 @@ export function getExperimentalAssetsBasePath( : path.resolve(path.dirname(config.configPath ?? "wrangler.toml")); } -export type RoutingConfig = { - hasUserWorker: boolean; -}; -export interface ExperimentalAssetsOptions extends ExperimentalAssets { +export type ExperimentalAssetsOptions = Pick< + ExperimentalAssets, + "directory" | "binding" +> & { routingConfig: RoutingConfig; -} + assetConfig: AssetConfig; +}; export function processExperimentalAssetsArg( args: { experimentalAssets: string | undefined; script?: string }, @@ -344,32 +349,23 @@ export function processExperimentalAssetsArg( experimentalAssets.directory = resolvedExperimentalAssetsPath; const routingConfig = { - hasUserWorker: Boolean(args.script || config.main), + has_user_worker: Boolean(args.script || config.main), + }; + // defaults are set in asset worker + const assetConfig = { + html_handling: config.experimental_assets?.html_handling, + not_found_handling: config.experimental_assets?.not_found_handling, }; experimentalAssetsOptions = { ...experimentalAssets, routingConfig, + assetConfig, }; } return experimentalAssetsOptions; } -const encodeFilePath = (filePath: string) => { - const encodedPath = filePath - .split(path.sep) - .map((segment) => encodeURIComponent(segment)) - .join("/"); - return "/" + encodedPath; -}; - -const decodeFilepath = (filePath: string) => { - return filePath - .split("/") - .map((segment) => decodeURIComponent(segment)) - .join(path.sep); -}; - /** * Create a function for filtering out ignored assets. * diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index cf7bb5e455d3..9f8fc0d81477 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -354,16 +354,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m : undefined; // Upload assets if experimental assets is being used - const experimentalAssetsOptions = + const experimentalAssetsJwt = props.experimentalAssetsOptions && !props.dryRun - ? { - routingConfig: props.experimentalAssetsOptions?.routingConfig, - jwt: await syncExperimentalAssets( - accountId, - scriptName, - props.experimentalAssetsOptions.directory - ), - } + ? await syncExperimentalAssets( + accountId, + scriptName, + props.experimentalAssetsOptions.directory + ) : undefined; const bindings: CfWorkerInit["bindings"] = { @@ -434,7 +431,14 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m "workers/message": props.message, "workers/tag": props.tag, }, - experimental_assets: experimentalAssetsOptions, + experimental_assets: + props.experimentalAssetsOptions && experimentalAssetsJwt + ? { + jwt: experimentalAssetsJwt, + routingConfig: props.experimentalAssetsOptions.routingConfig, + assetConfig: props.experimentalAssetsOptions.assetConfig, + } + : undefined, }; await printBundleSize( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f25d99b978..3f15732d11a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,30 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/asset-config: + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: ^0.4.29 + version: 0.4.30(@cloudflare/workers-types@4.20240909.0)(@vitest/runner@2.0.5)(@vitest/snapshot@2.0.5)(vitest@1.5.0(@types/node@20.12.12)(@vitest/ui@1.6.0(vitest@1.6.0))) + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../../packages/workers-tsconfig + '@cloudflare/workers-types': + specifier: ^4.20240821.1 + version: 4.20240909.0 + run-script-os: + specifier: ^1.1.6 + version: 1.1.6 + undici: + specifier: ^5.28.4 + version: 5.28.4 + vitest: + specifier: 1.5.0 + version: 1.5.0(@types/node@20.12.12)(@vitest/ui@1.6.0(vitest@1.6.0)) + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + fixtures/d1-worker-app: devDependencies: wrangler: @@ -1543,6 +1567,13 @@ importers: version: link:../wrangler packages/workers-shared: + dependencies: + mime: + specifier: ^3.0.0 + version: 3.0.0 + zod: + specifier: ^3.22.3 + version: 3.22.3 devDependencies: '@cloudflare/eslint-config-worker': specifier: workspace:* @@ -1553,6 +1584,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20240909.0 version: 4.20240909.0 + '@types/mime': + specifier: ^3.0.4 + version: 3.0.4 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -2443,6 +2477,10 @@ packages: peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 + '@cloudflare/kv-asset-handler@0.3.4': + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + '@cloudflare/style-const@5.7.3': resolution: {integrity: sha512-N9Y8bcFXoO7htm+sSVsBmQOVbjLeEY2hy1CBmvt0AoH1zWvs3izwJrnlL0ee4kJ6DkyjaY6SIAkUGUtTOApF3Q==} peerDependencies: @@ -2482,36 +2520,77 @@ packages: '@cloudflare/util-markdown@1.2.15': resolution: {integrity: sha512-H8q/Msk+9Fga6iqqmff7i4mi+kraBCQWFbMEaKIRq3+HBNN5gkpizk05DSG6iIHVxCG1M3WR1FkN9CQ0ZtK4Cw==} + '@cloudflare/vitest-pool-workers@0.4.30': + resolution: {integrity: sha512-+Hw/I7dDLvxhKfgRIMrguxeeNjI33Pl/vS1cOi3rltuRofTafciVwSV+n4UA24bHKJNKNFy5n3fJGh97oDV82A==} + peerDependencies: + '@vitest/runner': 1.3.x - 1.5.x + '@vitest/snapshot': 1.3.x - 1.5.x + vitest: 1.3.x - 1.5.x + + '@cloudflare/workerd-darwin-64@1.20240821.1': + resolution: {integrity: sha512-CDBpfZKrSy4YrIdqS84z67r3Tzal2pOhjCsIb63IuCnvVes59/ft1qhczBzk9EffeOE2iTCrA4YBT7Sbn7USew==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-64@1.20240909.0': resolution: {integrity: sha512-nJ8jm/6PR8DPzVb4QifNAfSdrFZXNblwIdOhLTU5FpSvFFocmzFX5WgzQagvtmcC9/ZAQyxuf7WynDNyBcoe0Q==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20240821.1': + resolution: {integrity: sha512-Q+9RedvNbPcEt/dKni1oN94OxbvuNAeJkgHmrLFTGF8zu21wzOhVkQeRNxcYxrMa9mfStc457NAg13OVCj2kHQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20240909.0': resolution: {integrity: sha512-gJqKa811oSsoxy9xuoQn7bS0Hr1sY+o3EUORTcEnulG6Kz9NQ6nd8QNdp2Hrk2jmmSqwrNkn+a6PZkWzk6Q0Gw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-linux-64@1.20240821.1': + resolution: {integrity: sha512-j6z3KsPtawrscoLuP985LbqFrmsJL6q1mvSXOXTqXGODAHIzGBipHARdOjms3UQqovzvqB2lQaQsZtLBwCZxtA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-64@1.20240909.0': resolution: {integrity: sha512-sJrmtccfMg73sZljiBpe4R+lhF58TqzqhF2pQG8HRjyxkzkM1sjpZqfEFaIkNUDqd3/Ibji49fklhPCGXljKSg==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20240821.1': + resolution: {integrity: sha512-I9bHgZOxJQW0CV5gTdilyxzTG7ILzbTirehQWgfPx9X77E/7eIbR9sboOMgyeC69W4he0SKtpx0sYZuTJu4ERw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20240909.0': resolution: {integrity: sha512-dTbSdceyRXPOSER+18AwYRbPQG0e/Dwl2trmfMMCETkfJhNLv1fU3FFMJPjfILijKnhTZHSnHCx0+xwHdon2fg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-windows-64@1.20240821.1': + resolution: {integrity: sha512-keC97QPArs6LWbPejQM7/Y8Jy8QqyaZow4/ZdsGo+QjlOLiZRDpAenfZx3CBUoWwEeFwQTl2FLO+8hV1SWFFYw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workerd-windows-64@1.20240909.0': resolution: {integrity: sha512-/d4BT0kcWFa7Qc0K4K9+cwVQ1qyPNKiO42JZUijlDlco+TYTPkLO3qGEohmwbfMq+BieK7JTMSgjO81ZHpA0HQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workers-shared@0.4.1': + resolution: {integrity: sha512-nYh4r8JwOOjYIdH2zub++CmIKlkYFlpxI1nBHimoiHcytJXD/b7ldJ21TtfzUZMCgI78mxVlymMHA/ReaOxKlA==} + engines: {node: '>=16.7.0'} + '@cloudflare/workers-types@4.20240909.0': resolution: {integrity: sha512-4knwtX6efxIsIxawdmPyynU9+S8A78wntU8eUIEldStWP4gNgxGkeWcfCMXulTx8oxr3DU4aevHyld9HGV8VKQ==} @@ -6604,6 +6683,11 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + miniflare@3.20240821.2: + resolution: {integrity: sha512-mgWekp437zD5l2Rz/in/OGBAISNB3rWr69DhR5Iq3WoToUNeAnkbW/CWPBpJiw5WHzZfHHOT+sVjSksAGRJitg==} + engines: {node: '>=16.13'} + hasBin: true + minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -7677,6 +7761,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + run-script-os@1.1.6: + resolution: {integrity: sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw==} + hasBin: true + rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -8773,11 +8861,26 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20240821.1: + resolution: {integrity: sha512-y4phjCnEG96u8ZkgkkHB+gSw0i6uMNo23rBmixylWpjxDklB+LWD8dztasvsu7xGaZbLoTxQESdEw956F7VJDA==} + engines: {node: '>=16'} + hasBin: true + workerd@1.20240909.0: resolution: {integrity: sha512-NwuYh/Fgr/MK0H+Ht687sHl/f8tumwT5CWzYR0MZMHri8m3CIYu2IaY4tBFWoKE/tOU1Z5XjEXECa9zXY4+lwg==} engines: {node: '>=16'} hasBin: true + wrangler@3.76.0: + resolution: {integrity: sha512-HQWJm5/RUHVr+Vg2KM55pjDSbcEh5WxR6Swcpz1jQ5o+ytoLoj31IHMsl4cJFfM/JtzjBXSpRbS00lDnGfFc2A==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20240821.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -9713,6 +9816,10 @@ snapshots: dependencies: react: 18.3.1 + '@cloudflare/kv-asset-handler@0.3.4': + dependencies: + mime: 3.0.0 + '@cloudflare/style-const@5.7.3(react@18.3.1)': dependencies: '@cloudflare/types': 6.23.6(react@18.3.1) @@ -9777,21 +9884,57 @@ snapshots: lodash.memoize: 4.1.2 marked: 0.3.19 + '@cloudflare/vitest-pool-workers@0.4.30(@cloudflare/workers-types@4.20240909.0)(@vitest/runner@2.0.5)(@vitest/snapshot@2.0.5)(vitest@1.5.0(@types/node@20.12.12)(@vitest/ui@1.6.0(vitest@1.6.0)))': + dependencies: + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + birpc: 0.2.14 + cjs-module-lexer: 1.2.3 + devalue: 4.3.2 + esbuild: 0.17.19 + miniflare: 3.20240821.2 + semver: 7.5.4 + vitest: 1.5.0(@types/node@20.12.12)(@vitest/ui@1.6.0(vitest@1.6.0)) + wrangler: 3.76.0(@cloudflare/workers-types@4.20240909.0) + zod: 3.22.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - supports-color + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20240821.1': + optional: true + '@cloudflare/workerd-darwin-64@1.20240909.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20240821.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20240909.0': optional: true + '@cloudflare/workerd-linux-64@1.20240821.1': + optional: true + '@cloudflare/workerd-linux-64@1.20240909.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20240821.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20240909.0': optional: true + '@cloudflare/workerd-windows-64@1.20240821.1': + optional: true + '@cloudflare/workerd-windows-64@1.20240909.0': optional: true + '@cloudflare/workers-shared@0.4.1': {} + '@cloudflare/workers-types@4.20240909.0': {} '@colors/colors@1.5.0': @@ -11413,7 +11556,7 @@ snapshots: pathe: 1.1.1 picocolors: 1.0.1 sirv: 2.0.4 - vitest: 1.6.0(@types/node@20.12.12)(@vitest/ui@1.6.0)(supports-color@9.2.2) + vitest: 1.6.0(@types/node@20.8.3)(@vitest/ui@1.6.0) '@vitest/utils@1.5.0': dependencies: @@ -14472,6 +14615,25 @@ snapshots: min-indent@1.0.1: {} + miniflare@3.20240821.2: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.11.3 + acorn-walk: 8.3.2 + capnp-ts: 0.7.0(patch_hash=l4yimnxyvkiyj6alnps2ec3sii) + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.28.4 + workerd: 1.20240821.1 + ws: 8.17.1 + youch: 3.2.3 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 @@ -15609,6 +15771,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + run-script-os@1.1.6: {} + rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -16468,6 +16632,23 @@ snapshots: vary@1.1.2: {} + vite-node@1.5.0(@types/node@20.12.12): + dependencies: + cac: 6.7.14 + debug: 4.3.5 + pathe: 1.1.2 + picocolors: 1.0.1 + vite: 5.0.12(@types/node@20.12.12) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vite-node@1.5.0(@types/node@20.8.3): dependencies: cac: 6.7.14 @@ -16596,6 +16777,40 @@ snapshots: mock-socket: 9.3.1 vitest: 1.6.0(@types/node@20.12.12)(@vitest/ui@1.6.0)(supports-color@9.2.2) + vitest@1.5.0(@types/node@20.12.12)(@vitest/ui@1.6.0(vitest@1.6.0)): + dependencies: + '@vitest/expect': 1.5.0 + '@vitest/runner': 1.5.0 + '@vitest/snapshot': 1.5.0 + '@vitest/spy': 1.5.0 + '@vitest/utils': 1.5.0 + acorn-walk: 8.3.2 + chai: 4.3.10 + debug: 4.3.5 + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.5 + pathe: 1.1.2 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 2.0.0 + tinybench: 2.6.0 + tinypool: 0.8.3 + vite: 5.0.12(@types/node@20.12.12) + vite-node: 1.5.0(@types/node@20.12.12) + why-is-node-running: 2.2.2 + optionalDependencies: + '@types/node': 20.12.12 + '@vitest/ui': 1.6.0(vitest@1.6.0) + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + vitest@1.5.0(@types/node@20.8.3)(@vitest/ui@1.6.0(vitest@1.6.0)): dependencies: '@vitest/expect': 1.5.0 @@ -16838,6 +17053,14 @@ snapshots: wordwrap@1.0.0: {} + workerd@1.20240821.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20240821.1 + '@cloudflare/workerd-darwin-arm64': 1.20240821.1 + '@cloudflare/workerd-linux-64': 1.20240821.1 + '@cloudflare/workerd-linux-arm64': 1.20240821.1 + '@cloudflare/workerd-windows-64': 1.20240821.1 + workerd@1.20240909.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20240909.0 @@ -16846,6 +17069,34 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20240909.0 '@cloudflare/workerd-windows-64': 1.20240909.0 + wrangler@3.76.0(@cloudflare/workers-types@4.20240909.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/workers-shared': 0.4.1 + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + chokidar: 3.5.3 + date-fns: 3.6.0 + esbuild: 0.17.19 + miniflare: 3.20240821.2 + nanoid: 3.3.7 + path-to-regexp: 6.2.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + selfsigned: 2.1.1 + source-map: 0.6.1 + unenv: unenv-nightly@2.0.0-1724863496.70db6f1 + workerd: 1.20240821.1 + xxhash-wasm: 1.0.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20240909.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0