chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
# @cloudflare/workers-shared
|
||||
|
||||
## 0.19.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#14314](https://github.com/cloudflare/workers-sdk/pull/14314) [`5c3bb11`](https://github.com/cloudflare/workers-sdk/commit/5c3bb118a99da70c5c1efb07df37f685e7044ba6) Thanks [@harryzcy](https://github.com/harryzcy)! - Bump esbuild to 0.28.1
|
||||
|
||||
This update includes several bug fixes from esbuild versions 0.27.3 through 0.28.1. See the [esbuild changelog](https://github.com/evanw/esbuild/blob/v0.28.1/CHANGELOG.md) for details.
|
||||
|
||||
## 0.19.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13855](https://github.com/cloudflare/workers-sdk/pull/13855) [`dba84c2`](https://github.com/cloudflare/workers-sdk/commit/dba84c225f418a3046ed736649044fe0b747aec0) Thanks [@courtney-sims](https://github.com/courtney-sims)! - Temporarily hardcode asset worker cohort to "ent" for latency testing
|
||||
|
||||
Disables the `lookupCohort` RPC call and cohort-based version routing in the outer entrypoint while keeping all the glue code (analytics, bindings, types) in place for re-enablement.
|
||||
|
||||
## 0.19.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13740](https://github.com/cloudflare/workers-sdk/pull/13740) [`6d2a573`](https://github.com/cloudflare/workers-sdk/commit/6d2a5736c6682882ba48aa3e272d9114bc821c47) Thanks [@courtney-sims](https://github.com/courtney-sims)! - During deployment, routes requests to new versions of asset-worker based on customer account plan.
|
||||
|
||||
- [#12276](https://github.com/cloudflare/workers-sdk/pull/12276) [`926bad5`](https://github.com/cloudflare/workers-sdk/commit/926bad5c75882e2dabb700f60f0da6ba3c4298f5) Thanks [@penalosa](https://github.com/penalosa)! - Warn when `_headers` rules contain multiple wildcards or wildcard combined with `:splat`
|
||||
|
||||
Rules containing multiple wildcards (e.g. `https://*.workers.dev/*`) or combining a wildcard with a `:splat` placeholder (e.g. `https://*.pages.dev/:splat`) are now rejected during parsing. Previously this would fail silently during dev.
|
||||
|
||||
## 0.19.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13363](https://github.com/cloudflare/workers-sdk/pull/13363) [`6457fb3`](https://github.com/cloudflare/workers-sdk/commit/6457fb38c7fbce39c396562bc3324b945114c672) Thanks [@courtney-sims](https://github.com/courtney-sims)! - Prepares router-worker for a more gradual rollout by refactoring and separating out the invocation from the business logic. In the future, this will provide space for us to route requests to new versions of router-worker based on their plan, but should make no functional difference today.
|
||||
|
||||
## 0.19.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13668](https://github.com/cloudflare/workers-sdk/pull/13668) [`ef24ff2`](https://github.com/cloudflare/workers-sdk/commit/ef24ff28d905ca3706a272653c52a342de3c4339) Thanks [@for-the-kidz](https://github.com/for-the-kidz)! - Fix `TypeError: rules is not iterable` in the router-worker when `static_routing` is configured without `user_worker` rules
|
||||
|
||||
The router-worker's static-routing include-rule evaluation passed `config.static_routing.user_worker` directly to the matcher, which iterates with `for...of`. When `static_routing` was set but `user_worker` was omitted, the matcher threw `TypeError: rules is not iterable` and failed the request. The adjacent `asset_worker` branch already falls back to `[]` in this case; the `user_worker` branch now does the same.
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13354](https://github.com/cloudflare/workers-sdk/pull/13354) [`854d66c`](https://github.com/cloudflare/workers-sdk/commit/854d66c30428cb3fe9ad8629089d9307c33d8b61) Thanks [@courtney-sims](https://github.com/courtney-sims)! - Prepares asset-worker for a more gradual rollout by refactoring and separating out the invocation from the business logic. In the future, this will provide space for us to route requests to new versions of asset-worker based on their plan, but should make no functional difference today.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12752](https://github.com/cloudflare/workers-sdk/pull/12752) [`00a4356`](https://github.com/cloudflare/workers-sdk/commit/00a43568d9dc3152befa278cdb61b4c7e89b42fa) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - fix: Normalize backslash characters in `/cdn-cgi` paths
|
||||
|
||||
Requests containing backslash characters in `/cdn-cgi` paths are now redirected to their normalized equivalents with forward slashes. This ensures consistent URL handling across different browsers and HTTP clients.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#12467](https://github.com/cloudflare/workers-sdk/pull/12467) [`7036310`](https://github.com/cloudflare/workers-sdk/commit/7036310b2d30a0ef794f6bd1a77e92c7ca0774fb) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Add support for inline comments in `_redirects` files
|
||||
|
||||
You can now add inline comments to redirect rules using the `#` character:
|
||||
|
||||
```
|
||||
/old-page /new-page 301 # Moved during site redesign
|
||||
/blog/* /articles/:splat # Blog URL migration
|
||||
```
|
||||
|
||||
This improves the maintainability of `_redirects` files by allowing documentation of complex redirect rules directly alongside the rules themselves. Full-line comments (lines starting with `#`) continue to work as before. URL fragments (e.g., `/page#section`) are preserved correctly.
|
||||
|
||||
## 0.18.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12416](https://github.com/cloudflare/workers-sdk/pull/12416) [`38ad241`](https://github.com/cloudflare/workers-sdk/commit/38ad2416ca2bb357857038991401cf07fd3820e1) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Revert reduced cache TTL. Restore 60-second TTL for KV asset fetches.
|
||||
|
||||
## 0.18.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11920](https://github.com/cloudflare/workers-sdk/pull/11920) [`09a8829`](https://github.com/cloudflare/workers-sdk/commit/09a8829d0bf5dbb5f94f5ad4d6d7aaacb940241e) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - The asset-worker now uses a shorter 10-second TTL for cached assets, down from 60 seconds. This ensures that asset updates are reflected more quickly during development and deployment, reducing the window where stale assets might be served.
|
||||
|
||||
## 0.18.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11448](https://github.com/cloudflare/workers-sdk/pull/11448) [`2b4813b`](https://github.com/cloudflare/workers-sdk/commit/2b4813b18076817bb739491246313c32b403651f) Thanks [@edmundhung](https://github.com/edmundhung)! - Builds package with esbuild `v0.27.0`
|
||||
|
||||
## 0.18.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10500](https://github.com/cloudflare/workers-sdk/pull/10500) [`19b1881`](https://github.com/cloudflare/workers-sdk/commit/19b1881edc1428c5b2d3beb905006238c5b33dba) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Block /\_image routes with href query param using double slash.
|
||||
|
||||
## 0.18.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10462](https://github.com/cloudflare/workers-sdk/pull/10462) [`c4e164c`](https://github.com/cloudflare/workers-sdk/commit/c4e164c13a830a4c6426c318061a0f13b4281c67) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Update mime dependency to 4.X so that javascript files will have content types of `text/javascript` instead of `application/javascript`. This will affect the content-types computed by Asset Worker within Workers Static Assets.
|
||||
|
||||
## 0.18.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10402](https://github.com/cloudflare/workers-sdk/pull/10402) [`8fd6dc0`](https://github.com/cloudflare/workers-sdk/commit/8fd6dc0f4de1c9c215fdbd44aa2644096bee7bd0) Thanks [@danielrs](https://github.com/danielrs)! - Sanitize double-slashes in asset-worker relative redirects.
|
||||
|
||||
Without sanitizing, some relative redirect patterns were being treated as external redirects.
|
||||
|
||||
## 0.18.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#10007](https://github.com/cloudflare/workers-sdk/pull/10007) [`d82c8e8`](https://github.com/cloudflare/workers-sdk/commit/d82c8e807d4f22cafe9ae3e9db5477ebe35c819b) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Bugfix: Removes unnecessary cloning of the request. This is no longer needed. We were also seeing failures in runtime for large files due to this.
|
||||
|
||||
- [#10008](https://github.com/cloudflare/workers-sdk/pull/10008) [`ca00d74`](https://github.com/cloudflare/workers-sdk/commit/ca00d741fbf4729785fbc5ec28110c873ee231dd) Thanks [@vicb](https://github.com/vicb)! - block responses with multiple Content Type values
|
||||
|
||||
## 0.18.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9935](https://github.com/cloudflare/workers-sdk/pull/9935) [`2765b88`](https://github.com/cloudflare/workers-sdk/commit/2765b88bbd2d6c84afbc17953f731fddd6ffab2d) Thanks [@vicb](https://github.com/vicb)! - allow plain text with charset
|
||||
|
||||
## 0.18.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9908](https://github.com/cloudflare/workers-sdk/pull/9908) [`ab75fd8`](https://github.com/cloudflare/workers-sdk/commit/ab75fd8303084fba48ee5131e4ccf19510aed831) Thanks [@GregBrimble](https://github.com/GregBrimble)! - perf: graduate asset-server binary search experiment to 100%
|
||||
|
||||
The improved iterative binary search implementation has been graduated from a 50% experiment to the default implementation. This provides better performance for asset manifest lookups by replacing the recursive binary search with an iterative approach.
|
||||
|
||||
## 0.18.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9892](https://github.com/cloudflare/workers-sdk/pull/9892) [`78e259a`](https://github.com/cloudflare/workers-sdk/commit/78e259ac75795887c1bac7ebcb6f4cc636dc39e9) Thanks [@GregBrimble](https://github.com/GregBrimble)! - perf: increase binary search experiment sample rate to 50%
|
||||
|
||||
- [#9884](https://github.com/cloudflare/workers-sdk/pull/9884) [`a60e9da`](https://github.com/cloudflare/workers-sdk/commit/a60e9daf63d0d4918c134a45512e861d399c96c3) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Adds metrics for time-to-dispatch to Router Worker
|
||||
|
||||
## 0.18.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9824](https://github.com/cloudflare/workers-sdk/pull/9824) [`8104705`](https://github.com/cloudflare/workers-sdk/commit/810470555f49c358b1ebc3f679183d1f8ea89028) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Allow "plain text" images when blocking vulnerable non-image responses
|
||||
|
||||
- [#9785](https://github.com/cloudflare/workers-sdk/pull/9785) [`07416ba`](https://github.com/cloudflare/workers-sdk/commit/07416ba644ef019f7cdccc7b3ed67f92abf03438) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Handle next apps hosted at a path other than the root when blocking vulnerable non-image requests
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#9661](https://github.com/cloudflare/workers-sdk/pull/9661) [`e216a76`](https://github.com/cloudflare/workers-sdk/commit/e216a76fba20acf06faad2d9b310a189fe3ccb08) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Limit free tier requests in the Router worker
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9635](https://github.com/cloudflare/workers-sdk/pull/9635) [`b066cf8`](https://github.com/cloudflare/workers-sdk/commit/b066cf836a5fc8436068be42bf74100c4bcd58ea) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Block possibly vulnerable requests to the router worker
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9416](https://github.com/cloudflare/workers-sdk/pull/9416) [`3383021`](https://github.com/cloudflare/workers-sdk/commit/33830214ff76ec4738b3e998370eca7568240e12) Thanks [@matthewdavidrodgers](https://github.com/matthewdavidrodgers)! - Adds support for static routing to Workers Assets
|
||||
|
||||
Implements the proposal noted here https://github.com/cloudflare/workers-sdk/discussions/9143
|
||||
|
||||
In brief: when static routing is present for a Worker with assets, routing via those static rules takes precedence. When a request is evaluated in the Router Worker, the request path is first compared to the `"asset_worker"` rules (which are to be specified via "negative" rules, e.g. `"!/api/assets"`). If any match, the request is forwarded directly to the Asset Worker. If instead any `"user_worker"` rules match, the request is forwarded directly to the User Worker. If neither match (or static routing was not provided), the existing behavior takes over.
|
||||
|
||||
As part of this explicit routing, when static routing is present, the check against `Sec-Fetch-Mode: navigate` (to determine if this should serve an asset or go to the User Worker for not_found_handling) is disabled. Routing can be controlled by setting routing rules via `assets.run_worker_first` in your Wrangler configuration file.
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9050](https://github.com/cloudflare/workers-sdk/pull/9050) [`1be5644`](https://github.com/cloudflare/workers-sdk/commit/1be56441dec5e8faea3b6c007c1a347bab1a4029) Thanks [@WalshyDev](https://github.com/WalshyDev)! - Adds tracing to \_headers & \_redirects in Workers Assets allowing Cloudflare employees to better debug customer issues regarding these features.
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9033](https://github.com/cloudflare/workers-sdk/pull/9033) [`2c50115`](https://github.com/cloudflare/workers-sdk/commit/2c501151d3d1a563681cdb300a298b83862b60e2) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - chore: convert wrangler.toml files into wrangler.jsonc ones
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9043](https://github.com/cloudflare/workers-sdk/pull/9043) [`7744f1a`](https://github.com/cloudflare/workers-sdk/commit/7744f1a3039e01f593b6e578588e07d1139d1d93) Thanks [@WalshyDev](https://github.com/WalshyDev)! - Adds tracing into asset-worker unstable methods so we can better measure/debug these.
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8887](https://github.com/cloudflare/workers-sdk/pull/8887) [`511be3d`](https://github.com/cloudflare/workers-sdk/commit/511be3d17559e482fedf559cb61158e329c11d24) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Add log message when `Sec-Fetch-Mode: navigate` is responsible for assets routing decision in `wrangler dev`
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8643](https://github.com/cloudflare/workers-sdk/pull/8643) [`75005e3`](https://github.com/cloudflare/workers-sdk/commit/75005e3dc3d3dfc5becb5caf0896cd407c6c8386) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Support customizing the metafile limits
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8443](https://github.com/cloudflare/workers-sdk/pull/8443) [`3b1d081`](https://github.com/cloudflare/workers-sdk/commit/3b1d081b89892aa877c33ac63877db3eccedf062) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Requests with a `Sec-Fetch-Mode: navigate` header, made to a project with `sec_fetch_mode_navigate_header_prefers_asset_serving` compatibility flag, will be routed to the asset-worker rather than a user Worker when no exact asset match is found.
|
||||
|
||||
Requests without that header will continue to be routed to the user Worker when no exact asset match is found.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7334](https://github.com/cloudflare/workers-sdk/pull/7334) [`869ec7b`](https://github.com/cloudflare/workers-sdk/commit/869ec7b916487ec43b958a27bdfea13588c5685f) Thanks [@penalosa](https://github.com/penalosa)! - Packages in Workers SDK now support the versions of Node that Node itself supports (Current, Active, Maintenance). Currently, that includes Node v18, v20, and v22.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8390](https://github.com/cloudflare/workers-sdk/pull/8390) [`53e6323`](https://github.com/cloudflare/workers-sdk/commit/53e63233c5b9bb786af3daea63c10ffe60a5d881) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Parse and apply metafiles (`_headers` and `_redirects`) in `wrangler dev` for Workers Assets
|
||||
|
||||
- [#8373](https://github.com/cloudflare/workers-sdk/pull/8373) [`08b8c46`](https://github.com/cloudflare/workers-sdk/commit/08b8c46872988da7599891f8f1700bcbc7f86968) Thanks [@WalshyDev](https://github.com/WalshyDev)! - Add `CF-Cache-Status` to Workers Assets responses to indicate if we returned a cached asset or not. This will also populate zone cache analytics and Logpush logs.
|
||||
|
||||
- [#8279](https://github.com/cloudflare/workers-sdk/pull/8279) [`aba0e9c`](https://github.com/cloudflare/workers-sdk/commit/aba0e9cad62e77cfa5fb3515ea9f89aa225059ed) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Add support for custom headers and redirects in asset-worker
|
||||
|
||||
## 0.14.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8338](https://github.com/cloudflare/workers-sdk/pull/8338) [`2d40989`](https://github.com/cloudflare/workers-sdk/commit/2d409892f1cf08f07f84d25dcab023bc20ada374) Thanks [@GregBrimble](https://github.com/GregBrimble)! - feat: Upload \_headers and \_redirects if present with Workers Assets as part of `wrangler deploy` and `wrangler versions upload`.
|
||||
|
||||
- [#8350](https://github.com/cloudflare/workers-sdk/pull/8350) [`56a8aed`](https://github.com/cloudflare/workers-sdk/commit/56a8aed9604491433154cc006d1ba2c3ab6ac1d1) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Removes non-public methods from asset-worker
|
||||
|
||||
## 0.14.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8247](https://github.com/cloudflare/workers-sdk/pull/8247) [`a9a4c33`](https://github.com/cloudflare/workers-sdk/commit/a9a4c33143b9f58673ac0cdd251957997275fa10) Thanks [@GregBrimble](https://github.com/GregBrimble)! - feat: Omits Content-Type header for files of an unknown extension in Workers Assets
|
||||
|
||||
## 0.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [`07613d3`](https://github.com/cloudflare/workers-sdk/commit/07613d3b231779466ca2528ce07385552ec73501) Thanks [@penalosa](https://github.com/penalosa)! - Trigger release after testing release process
|
||||
|
||||
## 0.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8138](https://github.com/cloudflare/workers-sdk/pull/8138) [`f465840`](https://github.com/cloudflare/workers-sdk/commit/f46584035cc18eef3bc40b26131cc00666ab4e11) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Remove the interpolation search experiment for asset manifest reading
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8116](https://github.com/cloudflare/workers-sdk/pull/8116) [`ee4873c`](https://github.com/cloudflare/workers-sdk/commit/ee4873c963b89b0091a2ff0952c274ef9dbf05ad) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Adds a RewriteFrames integration for workers-shared Sentry source-mappings.
|
||||
|
||||
- [#8122](https://github.com/cloudflare/workers-sdk/pull/8122) [`e8829e3`](https://github.com/cloudflare/workers-sdk/commit/e8829e3b152cdec6f5bf75713a98297f45bd60fe) Thanks [@GregBrimble](https://github.com/GregBrimble)! - fix: interpolation search method now checks for a single match at the beginning of every iteration
|
||||
|
||||
- [#8115](https://github.com/cloudflare/workers-sdk/pull/8115) [`dba3f21`](https://github.com/cloudflare/workers-sdk/commit/dba3f21587ad294c7e45737e7c776d033cd80dd8) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Bump interpolation search method for asset manifest reading to 1%
|
||||
|
||||
- [#8117](https://github.com/cloudflare/workers-sdk/pull/8117) [`08e37f6`](https://github.com/cloudflare/workers-sdk/commit/08e37f6e28c38e064aeca95de1b3d63d8cacbb2d) Thanks [@WalshyDev](https://github.com/WalshyDev)! - Change asset-worker and router-worker analytics to using version tag rather than version UUID.
|
||||
|
||||
- [#8126](https://github.com/cloudflare/workers-sdk/pull/8126) [`59eda4a`](https://github.com/cloudflare/workers-sdk/commit/59eda4af54f8eada5324bbd3014c41c934e566ac) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Add status code analytics to router-worker
|
||||
|
||||
- [#8105](https://github.com/cloudflare/workers-sdk/pull/8105) [`f2e6e74`](https://github.com/cloudflare/workers-sdk/commit/f2e6e748989e283642e8b1496a789ca2bcd15757) Thanks [@GregBrimble](https://github.com/GregBrimble)! - fix: Handles a divide by zero error that could occur when searching large manifests
|
||||
|
||||
- [#8127](https://github.com/cloudflare/workers-sdk/pull/8127) [`d4d5987`](https://github.com/cloudflare/workers-sdk/commit/d4d5987c9f567901542c22cb1df13e56cb286887) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Bump interpoplation search method for asset manifest reads to 50%
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8094](https://github.com/cloudflare/workers-sdk/pull/8094) [`d83dd19`](https://github.com/cloudflare/workers-sdk/commit/d83dd1912baf680df45cea4bbdeae77d2d7013aa) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Provides sentry sourcemap generation and upload on production deploys.
|
||||
|
||||
- [#8084](https://github.com/cloudflare/workers-sdk/pull/8084) [`2547c0f`](https://github.com/cloudflare/workers-sdk/commit/2547c0fcca80ac3fde9fed292c3c477218fbd096) Thanks [@GregBrimble](https://github.com/GregBrimble)! - Rollout interpolation search method for asset manifests (1 / 20,000 requests)
|
||||
|
||||
## 0.13.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8044](https://github.com/cloudflare/workers-sdk/pull/8044) [`7006630`](https://github.com/cloudflare/workers-sdk/commit/7006630cfe307da4840651bc8b6da102660e3f1a) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Adds analytics and code (zero-percent gated) for a new asset manifest search algorithm
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7989](https://github.com/cloudflare/workers-sdk/pull/7989) [`cf09cfa`](https://github.com/cloudflare/workers-sdk/commit/cf09cfa33db317740a54c8b6b035a7e84b95c6ec) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Adds additional error messaging for errors from KV.
|
||||
|
||||
- [#7996](https://github.com/cloudflare/workers-sdk/pull/7996) [`f9fd9df`](https://github.com/cloudflare/workers-sdk/commit/f9fd9df8f6e11d87bb34ed5005730de1d593989a) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Provide shorter message for KV GET errors.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7897](https://github.com/cloudflare/workers-sdk/pull/7897) [`34f9797`](https://github.com/cloudflare/workers-sdk/commit/34f9797822836b98edc4d8ddc6e2fb0ab322b864) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - chore: provides `run_worker_first` for Worker-script-first configuration. Deprecates `experimental_serve_directly`.
|
||||
|
||||
## 0.12.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7906](https://github.com/cloudflare/workers-sdk/pull/7906) [`f5eaf4b`](https://github.com/cloudflare/workers-sdk/commit/f5eaf4bd2fcfdf19a40dd3056fc9b36c2654605c) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Fixes bug in router-worker that prevents unexpected errors from being captured.
|
||||
|
||||
## 0.12.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7887](https://github.com/cloudflare/workers-sdk/pull/7887) [`cab3e37`](https://github.com/cloudflare/workers-sdk/commit/cab3e37f66e8cbcf0f16898eff1827db1126901b) Thanks [@WalshyDev](https://github.com/WalshyDev)! - chore: add more observability into asset-worker for the Workers team to better insight into how requests are handled.
|
||||
|
||||
- [#7887](https://github.com/cloudflare/workers-sdk/pull/7887) [`cab3e37`](https://github.com/cloudflare/workers-sdk/commit/cab3e37f66e8cbcf0f16898eff1827db1126901b) Thanks [@WalshyDev](https://github.com/WalshyDev)! - chore: add tracing into router-worker so the Workers team can have a better insight into into how requests are handled.
|
||||
|
||||
## 0.12.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7844](https://github.com/cloudflare/workers-sdk/pull/7844) [`92ed81e`](https://github.com/cloudflare/workers-sdk/commit/92ed81e9f35c4bc951da79be7fb08e7e60fb1f48) Thanks [@WalshyDev](https://github.com/WalshyDev)! - chore: plumb through account ID and Worker ID into the asset-worker and router-worker for use in analytics and error reporting.
|
||||
|
||||
## 0.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7808](https://github.com/cloudflare/workers-sdk/pull/7808) [`7faabeb`](https://github.com/cloudflare/workers-sdk/commit/7faabeb1d1534818d0e93fe4e4710e9b77af1bfb) Thanks [@WalshyDev](https://github.com/WalshyDev)! - fix: fix analytics not being logged for `asset-worker` in the case of a successful request.
|
||||
|
||||
## 0.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7790](https://github.com/cloudflare/workers-sdk/pull/7790) [`c588c8a`](https://github.com/cloudflare/workers-sdk/commit/c588c8a79592979ef62489516593df7ca5b96901) Thanks [@WalshyDev](https://github.com/WalshyDev)! - fix: remove `--experimental-versions` flag from the Asset/Router Workers `deploy` scripts, now that Wrangler has removed the flag.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7761](https://github.com/cloudflare/workers-sdk/pull/7761) [`bb85c9a`](https://github.com/cloudflare/workers-sdk/commit/bb85c9ac10d23407085ff8cd479bd0469835c60f) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Adds jaeger tracing for asset-worker.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7768](https://github.com/cloudflare/workers-sdk/pull/7768) [`97603f0`](https://github.com/cloudflare/workers-sdk/commit/97603f031b30b8a289519ff48f2c2c39b1396656) Thanks [@WalshyDev](https://github.com/WalshyDev)! - fix: on a 404 from KV, we do not want the asset to stay in cache for the normal 1 year TTL. Instead we want to re-insert with a 60s TTL to revalidate and prevent a bad 404 from persisting.
|
||||
|
||||
## 0.11.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7612](https://github.com/cloudflare/workers-sdk/pull/7612) [`2e78812`](https://github.com/cloudflare/workers-sdk/commit/2e78812ade7cd7361b023c90afe06221a52b79eb) Thanks [@Cherry](https://github.com/Cherry)! - fix: resolves an issue where a malformed path such as `https://example.com/%A0` would cause an unhandled error
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7598](https://github.com/cloudflare/workers-sdk/pull/7598) [`178fd01`](https://github.com/cloudflare/workers-sdk/commit/178fd0123d8d4baf9f395bd8aade2cf1dccb6aa8) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - Dummy workers-shared version bump
|
||||
|
||||
The Router Worker and Asset Worker of `workers-shared` are currently in a weird state that prevents us to redeploy them. The current versions of these workers are developer modified due to adding secrets. We want a CI controlled version to safely use these secrets.
|
||||
|
||||
This commit performs a dummy `workers-shared` version bump to unlock us from this blocked state.
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7465](https://github.com/cloudflare/workers-sdk/pull/7465) [`5449fe5`](https://github.com/cloudflare/workers-sdk/commit/5449fe54b15cf7c6dd12c385b0c8d2883c641b80) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Instrument analytics around assets.serve_directly
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7445](https://github.com/cloudflare/workers-sdk/pull/7445) [`f4ae6ee`](https://github.com/cloudflare/workers-sdk/commit/f4ae6ee17a0bd487aa0680a0a7c0757256dee36d) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Support for `assets.experimental_serve_directly` with `wrangler dev`
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7410](https://github.com/cloudflare/workers-sdk/pull/7410) [`6b21919`](https://github.com/cloudflare/workers-sdk/commit/6b21919a3d8042afa0270c825bc119e9b58c0455) Thanks [@WalshyDev](https://github.com/WalshyDev)! - chore: remove awaits from Asset Worker fetches. This has no user-facing impact.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7303](https://github.com/cloudflare/workers-sdk/pull/7303) [`0d314ed`](https://github.com/cloudflare/workers-sdk/commit/0d314ed14145d50b8fd00fdae8b31fb043f4d31a) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Preparatory work to allow invoking user worker ahead of assets
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7176](https://github.com/cloudflare/workers-sdk/pull/7176) [`476e5df`](https://github.com/cloudflare/workers-sdk/commit/476e5df5d9f0a2aa3d713160994da3e2a752418e) Thanks [@jamesopstad](https://github.com/jamesopstad)! - Remove incorrect logic in Asset Worker.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7318](https://github.com/cloudflare/workers-sdk/pull/7318) [`6ba5903`](https://github.com/cloudflare/workers-sdk/commit/6ba5903201de34cb3a8a5610fa11825279171a7e) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Prevent same-schema attacks
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7183](https://github.com/cloudflare/workers-sdk/pull/7183) [`08c6580`](https://github.com/cloudflare/workers-sdk/commit/08c6580494e702373d17ff7485988a8fae9af59e) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Fixes bug where indexId was never set for router-worker and asset-worker
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7053](https://github.com/cloudflare/workers-sdk/pull/7053) [`8dc2b7d`](https://github.com/cloudflare/workers-sdk/commit/8dc2b7d739239411ac29e419c22d22c291777042) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - Adds analytics to asset-worker
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6941](https://github.com/cloudflare/workers-sdk/pull/6941) [`fd43068`](https://github.com/cloudflare/workers-sdk/commit/fd430687ec1431be6c3af1b7420278b636c36e59) Thanks [@WillTaylorDev](https://github.com/WillTaylorDev)! - feat: Add observability to router-worker
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6728](https://github.com/cloudflare/workers-sdk/pull/6728) [`1ca313f`](https://github.com/cloudflare/workers-sdk/commit/1ca313f2041688cd13e25f0817e3b72dfc930bac) Thanks [@emily-shen](https://github.com/emily-shen)! - fix: remove filepath encoding on asset upload and handle sometimes-encoded characters
|
||||
|
||||
Some characters like [ ] @ are encoded by encodeURIComponent() but are often requested at an unencoded URL path.
|
||||
This change will make assets with filenames with these characters accessible at both the encoded and unencoded paths,
|
||||
but to use the encoded path as the canonical one, and to redirect requests to the canonical path if necessary.
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6712](https://github.com/cloudflare/workers-sdk/pull/6712) [`7a8bb17`](https://github.com/cloudflare/workers-sdk/commit/7a8bb17a5f35e11cba336ca1bc5ea16413291bc7) Thanks [@penalosa](https://github.com/penalosa)! - fix: Use D&C token when deploying
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6708](https://github.com/cloudflare/workers-sdk/pull/6708) [`31bfd37`](https://github.com/cloudflare/workers-sdk/commit/31bfd374cf6764c1e8a4491518c58cb112010340) Thanks [@penalosa](https://github.com/penalosa)! - fix: Use `pnpx` for `wrangler deploy`
|
||||
|
||||
- [#6709](https://github.com/cloudflare/workers-sdk/pull/6709) [`5d8547e`](https://github.com/cloudflare/workers-sdk/commit/5d8547e26e9f5e2eb9516b17a096cd1ea9f63469) Thanks [@GregBrimble](https://github.com/GregBrimble)! - fix: Add missing config params to asset-worker and router-worker
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6670](https://github.com/cloudflare/workers-sdk/pull/6670) [`fed1fda`](https://github.com/cloudflare/workers-sdk/commit/fed1fda90d1434b5ce214656249b0ad723ce48c1) Thanks [@GregBrimble](https://github.com/GregBrimble)! - chore: Rename asset-worker RPC methods as unstable\_\*
|
||||
|
||||
The Asset Worker is currently set up as a `WorkerEntrypoint` class so that it is able to accept RPC calls to any of its public methods. There are currently four such public methods defined on this Worker: `canFetch`, `getByETag`, `getByPathname` and `exists`. While we are stabilising the implementation details of these methods, we would like to prevent developers from having their Workers call these methods directly. To that end, we are adopting the `unstable_<method_name>` naming convention for all of the aforementioned methods, to indicate that they are still in flux and that they are not an established API contract.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6631](https://github.com/cloudflare/workers-sdk/pull/6631) [`59a0072`](https://github.com/cloudflare/workers-sdk/commit/59a0072740aa19f8d2b7524b993a7be35ba67fce) Thanks [@emily-shen](https://github.com/emily-shen)! - feat: 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.
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6588](https://github.com/cloudflare/workers-sdk/pull/6588) [`45ad2e0`](https://github.com/cloudflare/workers-sdk/commit/45ad2e0c83f1382e1662aadc2b145969ed9a719b) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - fix: Stabilize Workers naming across `workers-shared`
|
||||
|
||||
The Asset Worker and Router Worker use inconsistent naming conventions across `workers-shared`. This commit stabilizes the naming to Asset Worker and Router Worker and permutations of those.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6539](https://github.com/cloudflare/workers-sdk/pull/6539) [`6c057d1`](https://github.com/cloudflare/workers-sdk/commit/6c057d10b22e9a2e08aa066e074c792cff78d1da) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - feat: Add basic Asset Worker behaviour
|
||||
|
||||
This commit implements a basic Asset Worker behaviour, including:
|
||||
|
||||
- headers handling
|
||||
- `200`/`404`/`500` response handling
|
||||
- fetching data from KV
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6537](https://github.com/cloudflare/workers-sdk/pull/6537) [`f5bde66`](https://github.com/cloudflare/workers-sdk/commit/f5bde66914d24c59da35e051c5343c8f0554f782) Thanks [@emily-shen](https://github.com/emily-shen)! - feat: add basic Router Worker to workers-shared
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6403](https://github.com/cloudflare/workers-sdk/pull/6403) [`00f340f`](https://github.com/cloudflare/workers-sdk/commit/00f340f7c1709db777e80a8ea24d245909ff4486) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - feat: Extend KV plugin behaviour to support Workers assets
|
||||
|
||||
This commit extends Miniflare's KV plugin's behaviour to support Workers assets, and therefore enables the emulation of Workers with assets in local development.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#6370](https://github.com/cloudflare/workers-sdk/pull/6370) [`8a3c6c0`](https://github.com/cloudflare/workers-sdk/commit/8a3c6c00105a3420e46da660bd3f317b26f1c6d4) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - feat: Create very basic Asset Server Worker and plumb it into `wrangler dev`
|
||||
|
||||
These changes do the ground work needed in order to add Assets support for Workers in `wrangler dev`. They implement the following:
|
||||
|
||||
- it creates a new package called `workers-shared` that hosts the `Asset Server Worker`, and the `Router Worker`in the future
|
||||
- it scaffolds the `Asset Server Worker` in some very basic form, with basic configuration. Further behaviour implementation will follow in a subsequent PR
|
||||
- it does the ground work of plumbing ASW into Miniflare
|
||||
@@ -0,0 +1,32 @@
|
||||
# `@cloudflare/workers-shared`
|
||||
|
||||
This is a package that is used at Cloudflare to power some internal features of [Cloudflare Workers](https://developers.cloudflare.com/workers/), as well as their open-source equivalents here in workers-sdk and Wrangler.
|
||||
|
||||
> [!NOTE]
|
||||
> Since code in this package is used by the Workers infrastructure, it is important that PRs are given careful review with regards to how they could cause a failure in production.
|
||||
> Ideally, there should be comprehensive tests for changes being made to give extra confidence about the behavior.
|
||||
|
||||
## `asset-worker`
|
||||
|
||||
The Asset Worker.
|
||||
|
||||
For more details please refer to the dedicated README file.
|
||||
|
||||
## `router-worker`
|
||||
|
||||
The Router Worker.
|
||||
|
||||
For more details please refer to the dedicated README file.
|
||||
|
||||
## `Worker Deployment`
|
||||
|
||||
Router-worker and asset-worker are both version uploaded through the run `deploy` target in this package, which uploads a new version of these Workers.
|
||||
|
||||
There are two ways that the `deploy` script target can be invoked:
|
||||
|
||||
- The `changesets` workflow will execute whenever there is a release of `@cloudflare/workers-shared`
|
||||
- If a change is needed prior to a workers-shared release, it can be done manually, via the `workers-shared-deploy-production` workflow. This workflow will upload new vesions of asset-worker and router-worker based on the code in the `main` branch. (Note, a duplicate version may be uploaded on the next workers-shared release, if there is an associated changeset)
|
||||
|
||||
The pnpm `deploy` script should not be run manually.
|
||||
|
||||
Once new versions are uploaded (through either the manual workflow, or the changesets workflow), they can be safely deployed and monitored using Gradual Deployments through Health Mediated Deployments.
|
||||
@@ -0,0 +1,3 @@
|
||||
# `asset-worker`
|
||||
|
||||
The Asset Worker is a [Cloudflare Worker](https://developers.cloudflare.com/workers/) that is responsible of serving static assets. It is deployed in production and is also used in local `wrangler dev`.
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
export * from "./src/worker";
|
||||
export { default } from "./src/worker";
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ENABLEMENT_COMPATIBILITY_FLAGS } from "./compatibility-flags";
|
||||
import type { ReadyAnalytics } from "./types";
|
||||
|
||||
// This will allow us to make breaking changes to the analytic schema
|
||||
const VERSION = 1;
|
||||
|
||||
// Identifies which entrypoint produced the analytics row.
|
||||
// Values must stay in sync with the ClickHouse view in cloudflare/ch/ready-analytics.
|
||||
export enum EntrypointType {
|
||||
Outer = 0,
|
||||
Inner = 1,
|
||||
}
|
||||
|
||||
// When adding new columns please update the schema
|
||||
type Data = {
|
||||
// -- Indexes --
|
||||
accountId?: number;
|
||||
scriptId?: number;
|
||||
|
||||
// -- Doubles --
|
||||
// double1 - The time it takes for the whole request to complete in milliseconds
|
||||
requestTime?: number;
|
||||
// double2 - Colo ID
|
||||
coloId?: number;
|
||||
// double3 - Metal ID
|
||||
metalId?: number;
|
||||
// double4 - Colo tier (e.g. tier 1, tier 2, tier 3)
|
||||
coloTier?: number;
|
||||
// double5 - Response status code
|
||||
status?: number;
|
||||
// double6 - Compatibility flags
|
||||
compatibilityFlags?: string[]; // converted into a bitmask
|
||||
// double7 - Entrypoint discriminator (see EntrypointType enum)
|
||||
entrypoint?: EntrypointType;
|
||||
|
||||
// -- Blobs --
|
||||
// blob1 - Hostname of the request
|
||||
hostname?: string;
|
||||
// blob2 - User agent making the request
|
||||
userAgent?: string;
|
||||
// blob3 - Html handling option
|
||||
htmlHandling?: string;
|
||||
// blob4 - Not found handling option
|
||||
notFoundHandling?: string;
|
||||
// blob5 - Error message
|
||||
error?: string;
|
||||
// blob6 - The current version UUID of asset-worker
|
||||
version?: string;
|
||||
// blob7 - Region of the colo (e.g. WEUR)
|
||||
coloRegion?: string;
|
||||
// blob8 - The cache status of the request
|
||||
cacheStatus?: string;
|
||||
// blob9 - Account cohort ("ent", "paid", "free", "employee", or "unknown")
|
||||
cohort?: string;
|
||||
};
|
||||
|
||||
const COMPATIBILITY_FLAG_MASKS: Record<ENABLEMENT_COMPATIBILITY_FLAGS, number> =
|
||||
{
|
||||
assets_navigation_prefers_asset_serving: 1 << 0,
|
||||
// next_one: 1 << 1
|
||||
// one_after_that: 1 << 2
|
||||
// etc: 1 << 3
|
||||
};
|
||||
|
||||
export class Analytics {
|
||||
private data: Data = {};
|
||||
private readyAnalytics?: ReadyAnalytics;
|
||||
|
||||
constructor(readyAnalytics?: ReadyAnalytics) {
|
||||
this.readyAnalytics = readyAnalytics;
|
||||
}
|
||||
|
||||
setData(newData: Partial<Data>) {
|
||||
this.data = { ...this.data, ...newData };
|
||||
}
|
||||
|
||||
getData(key: keyof Data) {
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
write() {
|
||||
if (!this.readyAnalytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
let compatibilityFlagsBitmask = 0;
|
||||
for (const compatibilityFlag of this.data.compatibilityFlags || []) {
|
||||
const mask =
|
||||
COMPATIBILITY_FLAG_MASKS[
|
||||
compatibilityFlag as ENABLEMENT_COMPATIBILITY_FLAGS
|
||||
];
|
||||
if (mask) {
|
||||
compatibilityFlagsBitmask += mask;
|
||||
}
|
||||
}
|
||||
|
||||
this.readyAnalytics.logEvent({
|
||||
version: VERSION,
|
||||
accountId: this.data.accountId,
|
||||
indexId: this.data.scriptId?.toString(),
|
||||
doubles: [
|
||||
this.data.requestTime ?? -1, // double1
|
||||
this.data.coloId ?? -1, // double2
|
||||
this.data.metalId ?? -1, // double3
|
||||
this.data.coloTier ?? -1, // double4
|
||||
this.data.status ?? -1, // double5
|
||||
compatibilityFlagsBitmask, // double6
|
||||
this.data.entrypoint ?? -1, // double7
|
||||
],
|
||||
blobs: [
|
||||
this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes
|
||||
this.data.userAgent?.substring(0, 256), // blob2 - trim to 256 bytes
|
||||
this.data.htmlHandling, // blob3
|
||||
this.data.notFoundHandling, // blob4
|
||||
this.data.error?.substring(0, 256), // blob5 - trim to 256 bytes
|
||||
this.data.version, // blob6
|
||||
this.data.coloRegion, // blob7
|
||||
this.data.cacheStatus, // blob8
|
||||
this.data.cohort, // blob9
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
CONTENT_HASH_OFFSET,
|
||||
CONTENT_HASH_SIZE,
|
||||
ENTRY_SIZE,
|
||||
HEADER_SIZE,
|
||||
PATH_HASH_OFFSET,
|
||||
PATH_HASH_SIZE,
|
||||
} from "../../utils/constants";
|
||||
|
||||
export class AssetsManifest {
|
||||
private data: Uint8Array;
|
||||
|
||||
constructor(data: ArrayBuffer) {
|
||||
this.data = new Uint8Array(data);
|
||||
}
|
||||
|
||||
async get(pathname: string) {
|
||||
const pathHash = await hashPath(pathname);
|
||||
const entry = binarySearch(this.data, pathHash);
|
||||
return entry ? Uint8ToHexString(entry) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const hashPath = async (path: string) => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(path);
|
||||
const hashBuffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
data.buffer as ArrayBuffer
|
||||
);
|
||||
return new Uint8Array(hashBuffer, 0, PATH_HASH_SIZE);
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for an entry with the given hash path.
|
||||
*
|
||||
* @param manifest the manifest bytes
|
||||
* @param pathHash the path hash to find in the manifest
|
||||
* @returns The content hash when the entry is found and `false` otherwise
|
||||
*/
|
||||
export const binarySearch = (
|
||||
manifest: Uint8Array,
|
||||
pathHash: Uint8Array
|
||||
): Uint8Array | false => {
|
||||
if (pathHash.byteLength !== PATH_HASH_SIZE) {
|
||||
throw new TypeError(
|
||||
`Search value should have a length of ${PATH_HASH_SIZE}`
|
||||
);
|
||||
}
|
||||
|
||||
const numberOfEntries = (manifest.byteLength - HEADER_SIZE) / ENTRY_SIZE;
|
||||
|
||||
if (numberOfEntries === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lowIndex = 0;
|
||||
let highIndex = numberOfEntries - 1;
|
||||
|
||||
while (lowIndex <= highIndex) {
|
||||
const middleIndex = (lowIndex + highIndex) >> 1;
|
||||
|
||||
const cmp = comparePathHashWithEntry(pathHash, manifest, middleIndex);
|
||||
|
||||
if (cmp < 0) {
|
||||
highIndex = middleIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmp > 0) {
|
||||
lowIndex = middleIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return new Uint8Array(
|
||||
manifest.buffer,
|
||||
HEADER_SIZE + middleIndex * ENTRY_SIZE + CONTENT_HASH_OFFSET,
|
||||
CONTENT_HASH_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares a search value with a path hash in the manifest
|
||||
*
|
||||
* @param searchValue a `Uint8Array` of size `PATH_HASH_SIZE`
|
||||
* @param manifest the manifest bytes
|
||||
* @param entryIndex the index in the manifest of the entry to compare
|
||||
*/
|
||||
function comparePathHashWithEntry(
|
||||
searchValue: Uint8Array,
|
||||
manifest: Uint8Array,
|
||||
entryIndex: number
|
||||
) {
|
||||
let pathHashOffset = HEADER_SIZE + entryIndex * ENTRY_SIZE + PATH_HASH_OFFSET;
|
||||
for (let offset = 0; offset < PATH_HASH_SIZE; offset++, pathHashOffset++) {
|
||||
const s = searchValue[offset];
|
||||
const e = manifest[pathHashOffset];
|
||||
if (s === undefined || e === undefined) {
|
||||
throw new TypeError(
|
||||
`Unexpected undefined value at offset ${offset} during path hash comparison`
|
||||
);
|
||||
}
|
||||
if (s < e) {
|
||||
return -1;
|
||||
}
|
||||
if (s > e) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an Uint8Array to an hex string
|
||||
*
|
||||
* @param array The content hash
|
||||
* @returns padded hex string
|
||||
*/
|
||||
const Uint8ToHexString = (array: Uint8Array) => {
|
||||
return [...array].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AssetConfig } from "../../utils/types";
|
||||
|
||||
interface CompatibilityFlag {
|
||||
enable: `assets_${string}`;
|
||||
disable: `assets_${string}`;
|
||||
onByDefaultAfter?: string;
|
||||
}
|
||||
|
||||
export const SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING = {
|
||||
enable: "assets_navigation_prefers_asset_serving",
|
||||
disable: "assets_navigation_has_no_effect",
|
||||
onByDefaultAfter: "2025-04-01",
|
||||
} satisfies CompatibilityFlag;
|
||||
|
||||
const COMPATIBILITY_FLAGS = [
|
||||
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING,
|
||||
] as const;
|
||||
|
||||
export type ENABLEMENT_COMPATIBILITY_FLAGS =
|
||||
(typeof COMPATIBILITY_FLAGS)[number]["enable"];
|
||||
|
||||
export const resolveCompatibilityOptions = (configuration?: AssetConfig) => {
|
||||
const compatibilityDate = configuration?.compatibility_date ?? "2021-11-02";
|
||||
const compatibilityFlags = configuration?.compatibility_flags ?? [];
|
||||
|
||||
const resolvedCompatibilityFlags = compatibilityFlags;
|
||||
for (const compatibilityFlag of COMPATIBILITY_FLAGS) {
|
||||
if (
|
||||
compatibilityFlag.onByDefaultAfter &&
|
||||
compatibilityDate >= compatibilityFlag.onByDefaultAfter &&
|
||||
!resolvedCompatibilityFlags.find(
|
||||
(flag) => flag === compatibilityFlag.disable
|
||||
) &&
|
||||
!resolvedCompatibilityFlags.find(
|
||||
(flag) => flag === compatibilityFlag.enable
|
||||
)
|
||||
) {
|
||||
resolvedCompatibilityFlags.push(compatibilityFlag.enable);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
compatibilityDate,
|
||||
compatibilityFlags: resolvedCompatibilityFlags,
|
||||
};
|
||||
};
|
||||
|
||||
export const flagIsEnabled = (
|
||||
configuration: Required<AssetConfig>,
|
||||
compatibilityFlag: (typeof COMPATIBILITY_FLAGS)[number]
|
||||
) => {
|
||||
return !!configuration.compatibility_flags.find(
|
||||
(flag) => flag === compatibilityFlag.enable
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { resolveCompatibilityOptions } from "./compatibility-flags";
|
||||
import type { AssetConfig } from "../../utils/types";
|
||||
|
||||
export const normalizeConfiguration = (
|
||||
configuration?: AssetConfig
|
||||
): Required<AssetConfig> => {
|
||||
const compatibilityOptions = resolveCompatibilityOptions(configuration);
|
||||
|
||||
return {
|
||||
compatibility_date: compatibilityOptions.compatibilityDate,
|
||||
compatibility_flags: compatibilityOptions.compatibilityFlags,
|
||||
html_handling: configuration?.html_handling ?? "auto-trailing-slash",
|
||||
not_found_handling: configuration?.not_found_handling ?? "none",
|
||||
redirects: configuration?.redirects ?? {
|
||||
version: 1,
|
||||
staticRules: {},
|
||||
rules: {},
|
||||
},
|
||||
headers: configuration?.headers ?? {
|
||||
version: 2,
|
||||
rules: {},
|
||||
},
|
||||
has_static_routing: configuration?.has_static_routing ?? false,
|
||||
account_id: configuration?.account_id ?? -1,
|
||||
script_id: configuration?.script_id ?? -1,
|
||||
debug: configuration?.debug ?? false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// have the browser check in with the server to make sure its local cache is valid before using it
|
||||
export const CACHE_CONTROL_BROWSER = "public, max-age=0, must-revalidate";
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReadyAnalytics } from "./types";
|
||||
|
||||
// This will allow us to make breaking changes to the analytic schema
|
||||
const VERSION = 1;
|
||||
|
||||
// When adding new columns please update the schema
|
||||
type Data = {
|
||||
// -- Indexes --
|
||||
accountId?: number;
|
||||
experimentName?: string;
|
||||
|
||||
// -- Doubles --
|
||||
// double1 - The time it takes to read the manifest in milliseconds
|
||||
manifestReadTime?: number;
|
||||
|
||||
// -- Blobs --
|
||||
// blob1 current or perfTest version of binary search
|
||||
binarySearchVersion?: "current" | "perfTest" | "current-fallback";
|
||||
};
|
||||
|
||||
export class ExperimentAnalytics {
|
||||
private data: Data = {};
|
||||
private readyAnalytics?: ReadyAnalytics;
|
||||
|
||||
constructor(readyAnalytics?: ReadyAnalytics) {
|
||||
this.readyAnalytics = readyAnalytics;
|
||||
}
|
||||
|
||||
setData(newData: Partial<Data>) {
|
||||
this.data = { ...this.data, ...newData };
|
||||
}
|
||||
|
||||
getData(key: keyof Data) {
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
write() {
|
||||
if (!this.readyAnalytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyAnalytics.logEvent({
|
||||
version: VERSION,
|
||||
accountId: this.data.accountId,
|
||||
indexId: this.data.experimentName,
|
||||
doubles: [
|
||||
this.data.manifestReadTime ?? -1, // double1
|
||||
],
|
||||
blobs: [
|
||||
this.data.binarySearchVersion, // blob1
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
export type Environment = "production" | "staging" | "fed-prod";
|
||||
|
||||
export interface ReadyAnalytics {
|
||||
logEvent: (e: ReadyAnalyticsEvent) => void;
|
||||
}
|
||||
|
||||
export interface ReadyAnalyticsEvent {
|
||||
accountId?: number;
|
||||
indexId?: string;
|
||||
version?: number;
|
||||
doubles?: (number | undefined)[];
|
||||
blobs?: (string | undefined)[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { InternalServerErrorResponse } from "../../../utils/responses";
|
||||
import type { PerformanceTimer } from "../../../utils/performance";
|
||||
import type { Analytics } from "../analytics";
|
||||
import type { Toucan } from "toucan-js";
|
||||
|
||||
export function handleError(
|
||||
sentry: Toucan | undefined,
|
||||
analytics: Analytics,
|
||||
err: unknown
|
||||
) {
|
||||
try {
|
||||
const response = new InternalServerErrorResponse(err as Error);
|
||||
|
||||
// Log to Sentry if we can
|
||||
if (sentry) {
|
||||
sentry.captureException(err);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
analytics.setData({ error: err.message });
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("Error handling error", e);
|
||||
return new InternalServerErrorResponse(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export function submitMetrics(
|
||||
analytics: Analytics,
|
||||
performance: PerformanceTimer,
|
||||
startTimeMs: number
|
||||
) {
|
||||
try {
|
||||
analytics.setData({ requestTime: performance.now() - startTimeMs });
|
||||
analytics.write();
|
||||
} catch (e) {
|
||||
console.error("Error submitting metrics", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mockJaegerBinding } from "../../../utils/tracing";
|
||||
import {
|
||||
flagIsEnabled,
|
||||
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING,
|
||||
} from "../compatibility-flags";
|
||||
import { CACHE_CONTROL_BROWSER } from "../constants";
|
||||
import { HEADERS_VERSION } from "../handler";
|
||||
import { generateRulesMatcher, replacer } from "./rules-engine";
|
||||
import type { AssetConfig, JaegerTracing } from "../../../utils/types";
|
||||
import type { AssetIntentWithResolver } from "../handler";
|
||||
import type { Env } from "../worker";
|
||||
|
||||
/**
|
||||
* Returns a Headers object that contains additional headers (to those
|
||||
* present in the original request) that the Assets Server Worker
|
||||
* should attach to its response.
|
||||
*
|
||||
*/
|
||||
export function getAssetHeaders(
|
||||
{ eTag, resolver }: AssetIntentWithResolver,
|
||||
contentType: string | undefined,
|
||||
cacheStatus: string,
|
||||
request: Request,
|
||||
configuration: Required<AssetConfig>
|
||||
) {
|
||||
const headers = new Headers({
|
||||
ETag: `"${eTag}"`,
|
||||
});
|
||||
|
||||
if (contentType !== undefined) {
|
||||
headers.append("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (isCacheable(request)) {
|
||||
headers.append("Cache-Control", CACHE_CONTROL_BROWSER);
|
||||
}
|
||||
|
||||
// Attach CF-Cache-Status, this will show to users that we are caching assets
|
||||
// and it will also populate the cache fields through the logging pipeline.
|
||||
headers.append("CF-Cache-Status", cacheStatus);
|
||||
|
||||
if (
|
||||
configuration.debug &&
|
||||
resolver === "not-found" &&
|
||||
flagIsEnabled(
|
||||
configuration,
|
||||
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING
|
||||
)
|
||||
) {
|
||||
headers.append(
|
||||
"X-Mf-Additional-Response-Log",
|
||||
"`Sec-Fetch-Mode: navigate` header present - using `not_found_handling` behavior"
|
||||
);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function isCacheable(request: Request) {
|
||||
return !request.headers.has("Authorization") && !request.headers.has("Range");
|
||||
}
|
||||
|
||||
export function attachCustomHeaders(
|
||||
request: Request,
|
||||
response: Response,
|
||||
configuration: Required<AssetConfig>,
|
||||
env: Env
|
||||
) {
|
||||
const jaeger: JaegerTracing = env.JAEGER ?? mockJaegerBinding();
|
||||
return jaeger.enterSpan("add_headers", (span) => {
|
||||
// Iterate through rules and find rules that match the path
|
||||
const headersMatcher = generateRulesMatcher(
|
||||
configuration.headers?.version === HEADERS_VERSION
|
||||
? configuration.headers.rules
|
||||
: {},
|
||||
({ set = {}, unset = [] }, replacements) => {
|
||||
const replacedSet: Record<string, string> = {};
|
||||
Object.entries(set).forEach(([key, value]) => {
|
||||
replacedSet[key] = replacer(value, replacements);
|
||||
});
|
||||
return {
|
||||
set: replacedSet,
|
||||
unset,
|
||||
};
|
||||
}
|
||||
);
|
||||
const matches = headersMatcher({ request });
|
||||
|
||||
// This keeps track of every header that we've set from _headers
|
||||
// because we want to combine user declared headers but overwrite
|
||||
// existing and extra ones
|
||||
const setMap = new Set();
|
||||
// Apply every matched rule in order
|
||||
matches.forEach(({ set = {}, unset = [] }) => {
|
||||
unset.forEach((key) => {
|
||||
response.headers.delete(key);
|
||||
span.addLogs({ remove_header: key });
|
||||
});
|
||||
Object.entries(set).forEach(([key, value]) => {
|
||||
if (setMap.has(key.toLowerCase())) {
|
||||
response.headers.append(key, value);
|
||||
span.addLogs({ append_header: key });
|
||||
} else {
|
||||
response.headers.set(key, value);
|
||||
setMap.add(key.toLowerCase());
|
||||
span.addLogs({ add_header: key });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Toucan } from "toucan-js";
|
||||
|
||||
export type AssetMetadata = {
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export async function getAssetWithMetadataFromKV(
|
||||
assetsKVNamespace: KVNamespace,
|
||||
assetKey: string,
|
||||
sentry?: Toucan,
|
||||
retries = 1
|
||||
) {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts <= retries) {
|
||||
try {
|
||||
const asset = await assetsKVNamespace.getWithMetadata<AssetMetadata>(
|
||||
assetKey,
|
||||
{
|
||||
type: "stream",
|
||||
cacheTtl: 31536000, // 1 year
|
||||
}
|
||||
);
|
||||
|
||||
if (asset.value === null) {
|
||||
// Don't cache a 404 for a year by re-requesting with a minimum cacheTtl
|
||||
const retriedAsset =
|
||||
await assetsKVNamespace.getWithMetadata<AssetMetadata>(assetKey, {
|
||||
type: "stream",
|
||||
cacheTtl: 60, // Minimum value allowed
|
||||
});
|
||||
|
||||
if (retriedAsset.value !== null && sentry) {
|
||||
sentry.captureException(
|
||||
new Error(
|
||||
`Initial request for asset ${assetKey} failed, but subsequent request succeeded.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return retriedAsset;
|
||||
}
|
||||
return asset;
|
||||
} catch (err) {
|
||||
if (attempts >= retries) {
|
||||
let message = `KV GET ${assetKey} failed.`;
|
||||
if (err instanceof Error) {
|
||||
message = `KV GET ${assetKey} failed: ${err.message}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Exponential backoff, 1 second first time, then 2 second, then 4 second etc.
|
||||
await new Promise((resolvePromise) =>
|
||||
setTimeout(resolvePromise, Math.pow(2, attempts++) * 1000)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Taken from https://stackoverflow.com/a/3561711
|
||||
// which is everything from the tc39 proposal, plus the following two characters: ^/
|
||||
// It's also everything included in the URLPattern escape (https://wicg.github.io/urlpattern/#escape-a-regexp-string), plus the following: -
|
||||
|
||||
import { REDIRECTS_VERSION } from "../handler";
|
||||
import type { AssetConfig } from "../../../utils/types";
|
||||
|
||||
// As the answer says, there's no downside to escaping these extra characters, so better safe than sorry
|
||||
const ESCAPE_REGEX_CHARACTERS = /[-/\\^$*+?.()|[\]{}]/g;
|
||||
const escapeRegex = (str: string) => {
|
||||
return str.replace(ESCAPE_REGEX_CHARACTERS, "\\$&");
|
||||
};
|
||||
|
||||
// Placeholder names must begin with a colon then a letter, be alphanumeric and optionally contain underscores.
|
||||
// e.g. :place_123_holder
|
||||
const HOST_PLACEHOLDER_REGEX =
|
||||
/(?<=^https:\\\/\\\/[^/]*?):([A-Za-z]\w*)(?=\\)/g;
|
||||
const PLACEHOLDER_REGEX = /:([A-Za-z]\w*)/g;
|
||||
|
||||
export type Replacements = Record<string, string>;
|
||||
|
||||
export type Removals = string[];
|
||||
|
||||
export const replacer = (str: string, replacements: Replacements) => {
|
||||
for (const [replacement, value] of Object.entries(replacements)) {
|
||||
str = str.replaceAll(`:${replacement}`, value);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
export const generateGlobOnlyRuleRegExp = (rule: string) => {
|
||||
// Escape all regex characters other than globs (the "*" character) since that's all that's supported.
|
||||
rule = rule.split("*").map(escapeRegex).join(".*");
|
||||
|
||||
// Wrap in line terminators to be safe.
|
||||
rule = "^" + rule + "$";
|
||||
|
||||
return RegExp(rule);
|
||||
};
|
||||
|
||||
export const generateRuleRegExp = (rule: string) => {
|
||||
// Create :splat capturer then escape.
|
||||
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
|
||||
|
||||
// Create :placeholder capturers (already escaped).
|
||||
// For placeholders in the host, we separate at forward slashes and periods.
|
||||
// For placeholders in the path, we separate at forward slashes.
|
||||
// This matches the behavior of URLPattern.
|
||||
// e.g. https://:subdomain.domain/ -> https://(here).domain/
|
||||
// e.g. /static/:file -> /static/(image.jpg)
|
||||
// e.g. /blog/:post -> /blog/(an-exciting-post)
|
||||
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
|
||||
for (const host_match of host_matches) {
|
||||
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
|
||||
}
|
||||
|
||||
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
|
||||
for (const path_match of path_matches) {
|
||||
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
|
||||
}
|
||||
|
||||
// Wrap in line terminators to be safe.
|
||||
rule = "^" + rule + "$";
|
||||
|
||||
return RegExp(rule);
|
||||
};
|
||||
|
||||
export const generateRulesMatcher = <T>(
|
||||
rules?: Record<string, T>,
|
||||
replacerFn: (match: T, replacements: Replacements) => T = (match) => match
|
||||
) => {
|
||||
if (!rules) {
|
||||
return () => [];
|
||||
}
|
||||
|
||||
const compiledRules = Object.entries(rules)
|
||||
.map(([rule, match]) => {
|
||||
const crossHost = rule.startsWith("https://");
|
||||
|
||||
try {
|
||||
const regExp = generateRuleRegExp(rule);
|
||||
return [{ crossHost, regExp }, match];
|
||||
} catch {}
|
||||
})
|
||||
.filter((value) => value !== undefined) as [
|
||||
{ crossHost: boolean; regExp: RegExp },
|
||||
T,
|
||||
][];
|
||||
|
||||
return ({ request }: { request: Request }) => {
|
||||
const { pathname, hostname } = new URL(request.url);
|
||||
|
||||
return compiledRules
|
||||
.map(([{ crossHost, regExp }, match]) => {
|
||||
// This, rather confusingly, means that although we enforce `https://` protocols in
|
||||
// the rules of `_headers`/`_redirects`, we don't actually respect that at all at runtime.
|
||||
// When processing a request against an absolute URL rule, we rewrite the protocol to `https://`.
|
||||
// This has the benefit of ensuring attackers can't specify a different protocol
|
||||
// to circumvent a developer's security rules (e.g. CORS), but it isn't obvious behavior.
|
||||
// We should consider different syntax in the future for developers when they specify rules.
|
||||
// For example, `*://example.com/path`, `://example.com/path` or `//example.com/`.
|
||||
// Though we'd need to be careful with that last one
|
||||
// as that would currently be read as a relative URL.
|
||||
// Perhaps, if we ever move the `_headers`/`_redirects` files to acting ahead of Functions,
|
||||
// this might be a good time for this change.
|
||||
const test = crossHost ? `https://${hostname}${pathname}` : pathname;
|
||||
const result = regExp.exec(test);
|
||||
if (result) {
|
||||
return replacerFn(match, result.groups || {});
|
||||
}
|
||||
})
|
||||
.filter((value) => value !== undefined) as T[];
|
||||
};
|
||||
};
|
||||
|
||||
export const staticRedirectsMatcher = (
|
||||
configuration: Required<AssetConfig>,
|
||||
host: string,
|
||||
pathname: string
|
||||
) => {
|
||||
const withHostMatch =
|
||||
configuration.redirects.staticRules[`https://${host}${pathname}`];
|
||||
const withoutHostMatch = configuration.redirects.staticRules[pathname];
|
||||
|
||||
if (withHostMatch && withoutHostMatch) {
|
||||
if (withHostMatch.lineNumber < withoutHostMatch.lineNumber) {
|
||||
return withHostMatch;
|
||||
} else {
|
||||
return withoutHostMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return withHostMatch || withoutHostMatch;
|
||||
};
|
||||
|
||||
export const generateRedirectsMatcher = (
|
||||
configuration: Required<AssetConfig>
|
||||
) =>
|
||||
generateRulesMatcher(
|
||||
configuration.redirects.version === REDIRECTS_VERSION
|
||||
? configuration.redirects.rules
|
||||
: {},
|
||||
({ status, to }, replacements) => {
|
||||
const target = replacer(to, replacements).trim();
|
||||
const protoPattern = /^(\w+:\/\/)/;
|
||||
if (protoPattern.test(target)) {
|
||||
// External redirects are not modified.
|
||||
return {
|
||||
status,
|
||||
to: target,
|
||||
};
|
||||
} else {
|
||||
// Relative redirects are modified to remove multiple slashes.
|
||||
return {
|
||||
status,
|
||||
to: target.replace(/\/+/g, "/"),
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const generateStaticRoutingRuleMatcher =
|
||||
(rules: string[]) =>
|
||||
({ request }: { request: Request }) => {
|
||||
const { pathname } = new URL(request.url);
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
const regExp = generateGlobOnlyRuleRegExp(rule);
|
||||
if (regExp.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,578 @@
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { PerformanceTimer } from "../../utils/performance";
|
||||
import { setupSentry } from "../../utils/sentry";
|
||||
import { mockJaegerBinding } from "../../utils/tracing";
|
||||
import { Analytics, EntrypointType } from "./analytics";
|
||||
import { AssetsManifest } from "./assets-manifest";
|
||||
import { normalizeConfiguration } from "./configuration";
|
||||
import { ExperimentAnalytics } from "./experiment-analytics";
|
||||
import { canFetch, handleRequest } from "./handler";
|
||||
import { handleError, submitMetrics } from "./utils/final-operations";
|
||||
import { getAssetWithMetadataFromKV } from "./utils/kv";
|
||||
import type {
|
||||
AssetConfig,
|
||||
ColoMetadata,
|
||||
JaegerTracing,
|
||||
SpanContext,
|
||||
UnsafePerformanceTimer,
|
||||
} from "../../utils/types";
|
||||
import type { AccountCohortQuerierBinding } from "../worker-configuration";
|
||||
import type { Environment, ReadyAnalytics } from "./types";
|
||||
|
||||
// ============================================================
|
||||
// SECTION 1: SHARED TYPES & INTERFACE CONTRACT
|
||||
// ============================================================
|
||||
|
||||
export 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;
|
||||
|
||||
SENTRY_DSN: string;
|
||||
SENTRY_ACCESS_CLIENT_ID: string;
|
||||
SENTRY_ACCESS_CLIENT_SECRET: string;
|
||||
|
||||
JAEGER: JaegerTracing;
|
||||
|
||||
ENVIRONMENT: Environment;
|
||||
EXPERIMENT_ANALYTICS: ReadyAnalytics;
|
||||
ANALYTICS: ReadyAnalytics;
|
||||
COLO_METADATA: ColoMetadata;
|
||||
UNSAFE_PERFORMANCE: UnsafePerformanceTimer;
|
||||
VERSION_METADATA: WorkerVersionMetadata;
|
||||
ACCOUNT_COHORT_QUERIER?: AccountCohortQuerierBinding;
|
||||
};
|
||||
|
||||
export type AssetWorkerEntrypointProps = {
|
||||
traceContext?: SpanContext | null;
|
||||
};
|
||||
|
||||
type GetByETagResult = {
|
||||
readableStream: ReadableStream;
|
||||
contentType: string | undefined;
|
||||
cacheStatus: "HIT" | "MISS";
|
||||
};
|
||||
|
||||
type ExistsFn = (pathname: string, request?: Request) => Promise<string | null>;
|
||||
type GetByETagFn = (
|
||||
eTag: string,
|
||||
request?: Request
|
||||
) => Promise<GetByETagResult>;
|
||||
|
||||
/**
|
||||
* Interface defining the public API methods that both the outer and inner
|
||||
* entrypoints implement. The outer entrypoint (AssetWorkerOuter) routes to
|
||||
* the inner entrypoint (AssetWorkerInner) via ctx.exports.
|
||||
*/
|
||||
interface AssetWorkerMethods {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
unstable_canFetch(request: Request): Promise<boolean>;
|
||||
unstable_getByETag(eTag: string, request?: Request): Promise<GetByETagResult>;
|
||||
unstable_getByPathname(
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult | null>;
|
||||
unstable_exists(pathname: string, request?: Request): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context type used for communication between outer and inner entrypoints
|
||||
* via ctx.exports. AssetWorkerOuter accesses the inner factory via exports,
|
||||
* AssetWorkerInner receives props via ctx.props.
|
||||
*/
|
||||
type AssetWorkerContext = ExecutionContext & {
|
||||
exports?: {
|
||||
AssetWorkerInner?: (options: {
|
||||
props: AssetWorkerEntrypointProps;
|
||||
version?: { cohort?: string };
|
||||
}) => AssetWorkerMethods;
|
||||
};
|
||||
props?: AssetWorkerEntrypointProps;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// SECTION 2: SHARED IMPLEMENTATION FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
async function unstableExistsImpl(
|
||||
env: Env,
|
||||
pathname: string,
|
||||
_request?: Request
|
||||
): Promise<string | null> {
|
||||
const analytics = new ExperimentAnalytics(env.EXPERIMENT_ANALYTICS);
|
||||
const performance = new PerformanceTimer(env.UNSAFE_PERFORMANCE);
|
||||
const jaeger = env.JAEGER ?? mockJaegerBinding();
|
||||
return jaeger.enterSpan("unstable_exists", async (span) => {
|
||||
if (env.COLO_METADATA && env.VERSION_METADATA && env.CONFIG) {
|
||||
analytics.setData({
|
||||
accountId: env.CONFIG.account_id,
|
||||
experimentName: "manifest-read-timing",
|
||||
});
|
||||
}
|
||||
|
||||
const startTimeMs = performance.now();
|
||||
try {
|
||||
const assetsManifest = new AssetsManifest(env.ASSETS_MANIFEST);
|
||||
const eTag = await assetsManifest.get(pathname);
|
||||
|
||||
span.setTags({
|
||||
path: pathname,
|
||||
found: eTag !== null,
|
||||
etag: eTag ?? "",
|
||||
});
|
||||
|
||||
return eTag;
|
||||
} finally {
|
||||
analytics.setData({
|
||||
manifestReadTime: performance.now() - startTimeMs,
|
||||
});
|
||||
analytics.write();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function unstableGetByETagImpl(
|
||||
env: Env,
|
||||
eTag: string,
|
||||
_request?: Request
|
||||
): Promise<GetByETagResult> {
|
||||
const performance = new PerformanceTimer(env.UNSAFE_PERFORMANCE);
|
||||
const jaeger = env.JAEGER ?? mockJaegerBinding();
|
||||
return jaeger.enterSpan("unstable_getByETag", async (span) => {
|
||||
const startTime = performance.now();
|
||||
const asset = await getAssetWithMetadataFromKV(
|
||||
env.ASSETS_KV_NAMESPACE,
|
||||
eTag
|
||||
);
|
||||
const endTime = performance.now();
|
||||
const assetFetchTime = endTime - startTime;
|
||||
|
||||
if (!asset || !asset.value) {
|
||||
span.setTags({
|
||||
error: true,
|
||||
});
|
||||
span.addLogs({
|
||||
error: `Requested asset ${eTag} exists in the asset manifest but not in the KV namespace.`,
|
||||
});
|
||||
throw new Error(
|
||||
`Requested asset ${eTag} exists in the asset manifest but not in the KV namespace.`
|
||||
);
|
||||
}
|
||||
|
||||
// KV does not yet provide a way to check if a value was fetched from cache
|
||||
// so we assume that if the fetch time is less than 100ms, it was a cache hit.
|
||||
// This is a reasonable assumption given the data we have and how KV works.
|
||||
const cacheStatus = assetFetchTime <= 100 ? "HIT" : "MISS";
|
||||
|
||||
span.setTags({
|
||||
etag: eTag,
|
||||
contentType: asset.metadata?.contentType ?? "unknown",
|
||||
cacheStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
readableStream: asset.value,
|
||||
contentType: asset.metadata?.contentType,
|
||||
cacheStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function unstableGetByPathnameImpl(
|
||||
env: Env,
|
||||
exists: ExistsFn,
|
||||
getByETag: GetByETagFn,
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult | null> {
|
||||
const jaeger = env.JAEGER ?? mockJaegerBinding();
|
||||
return jaeger.enterSpan("unstable_getByPathname", async (span) => {
|
||||
const eTag = await exists(pathname, request);
|
||||
|
||||
span.setTags({
|
||||
path: pathname,
|
||||
found: eTag !== null,
|
||||
});
|
||||
|
||||
if (!eTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getByETag(eTag, request);
|
||||
});
|
||||
}
|
||||
|
||||
export const COHORT_LOOKUP_TIMEOUT_MS = 5;
|
||||
|
||||
/**
|
||||
* Resolves the deployment cohort for a customer account via the
|
||||
* AccountCohortQuerier RPC binding. Returns null when the binding is
|
||||
* unavailable, the RPC fails, times out, or the cohort is undetermined
|
||||
* (cold cache). Intentionally fails open — a null cohort means the
|
||||
* request runs under default routing.
|
||||
*/
|
||||
export async function lookupCohort(
|
||||
env: Env,
|
||||
accountId: number | undefined
|
||||
): Promise<string | null> {
|
||||
const querier = env.ACCOUNT_COHORT_QUERIER;
|
||||
if (!querier || !accountId) {
|
||||
return null;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
try {
|
||||
const rpc = querier.lookupAccountCohort(accountId.toString());
|
||||
// Prevent unhandled rejection if timeout wins but RPC later rejects.
|
||||
void rpc.catch(() => {});
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
reject(new Error("cohort lookup timed out"));
|
||||
}, COHORT_LOOKUP_TIMEOUT_MS);
|
||||
ac.signal.addEventListener("abort", () => clearTimeout(id));
|
||||
});
|
||||
const res = await Promise.race([rpc, timeout]);
|
||||
if (!res.ok) {
|
||||
console.error("cohort lookup failed", res.errors);
|
||||
return null;
|
||||
}
|
||||
return res.result ?? null;
|
||||
} catch (e: unknown) {
|
||||
console.error("cohort lookup failed", e);
|
||||
return null;
|
||||
} finally {
|
||||
ac.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function runFetchRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
exists: ExistsFn,
|
||||
getByETag: GetByETagFn,
|
||||
cohort?: string
|
||||
): Promise<Response> {
|
||||
let sentry: ReturnType<typeof setupSentry> | undefined;
|
||||
const analytics = new Analytics(env.ANALYTICS);
|
||||
const performance = new PerformanceTimer(env.UNSAFE_PERFORMANCE);
|
||||
const startTimeMs = performance.now();
|
||||
|
||||
try {
|
||||
// TODO: Mock this with Miniflare
|
||||
env.JAEGER ??= mockJaegerBinding();
|
||||
|
||||
sentry = setupSentry(
|
||||
request,
|
||||
ctx,
|
||||
env.SENTRY_DSN,
|
||||
env.SENTRY_ACCESS_CLIENT_ID,
|
||||
env.SENTRY_ACCESS_CLIENT_SECRET,
|
||||
env.COLO_METADATA,
|
||||
env.VERSION_METADATA,
|
||||
env.CONFIG?.account_id,
|
||||
env.CONFIG?.script_id
|
||||
);
|
||||
|
||||
const config = normalizeConfiguration(env.CONFIG);
|
||||
sentry?.setContext("compatibilityOptions", {
|
||||
compatibilityDate: config.compatibility_date,
|
||||
compatibilityFlags: config.compatibility_flags,
|
||||
originalCompatibilityFlags: env.CONFIG.compatibility_flags,
|
||||
});
|
||||
const userAgent = request.headers.get("user-agent") ?? "UA UNKNOWN";
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (env.COLO_METADATA && env.VERSION_METADATA && env.CONFIG) {
|
||||
analytics.setData({
|
||||
accountId: env.CONFIG.account_id,
|
||||
scriptId: env.CONFIG.script_id,
|
||||
|
||||
coloId: env.COLO_METADATA.coloId,
|
||||
metalId: env.COLO_METADATA.metalId,
|
||||
coloTier: env.COLO_METADATA.coloTier,
|
||||
|
||||
coloRegion: env.COLO_METADATA.coloRegion,
|
||||
version: env.VERSION_METADATA.tag,
|
||||
hostname: url.hostname,
|
||||
htmlHandling: config.html_handling,
|
||||
notFoundHandling: config.not_found_handling,
|
||||
compatibilityFlags: config.compatibility_flags,
|
||||
userAgent: userAgent,
|
||||
entrypoint: EntrypointType.Inner,
|
||||
cohort: cohort ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
return await env.JAEGER.enterSpan("handleRequest", async (span) => {
|
||||
span.setTags({
|
||||
hostname: url.hostname,
|
||||
eyeballPath: url.pathname,
|
||||
env: env.ENVIRONMENT,
|
||||
version: env.VERSION_METADATA?.id,
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
request,
|
||||
env,
|
||||
config,
|
||||
exists,
|
||||
getByETag,
|
||||
analytics
|
||||
);
|
||||
|
||||
analytics.setData({ status: response.status });
|
||||
|
||||
return response;
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(sentry, analytics, err);
|
||||
} finally {
|
||||
submitMetrics(analytics, performance, startTimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 3: OUTER ENTRYPOINT (AssetWorkerOuter)
|
||||
// ============================================================
|
||||
|
||||
/*
|
||||
* The Asset Worker is currently set up as a `WorkerEntrypoint` class so
|
||||
* that it is able to accept RPC calls to any of its public methods. There
|
||||
* are currently four such public methods defined on this Worker:
|
||||
* `canFetch`, `getByETag`, `getByPathname` and `exists`. While we are
|
||||
* stabilizing the implementation details of these methods, we would like
|
||||
* to prevent developers from having their Workers call these methods
|
||||
* directly. To that end, we are adopting the `unstable_<method_name>`
|
||||
* naming convention for all of the aforementioned methods, to indicate that
|
||||
* they are still in flux and that they are not an established API contract.
|
||||
*
|
||||
* AssetWorkerOuter serves as the dispatch layer. It forwards all method
|
||||
* calls to AssetWorkerInner via ctx.exports.
|
||||
*/
|
||||
export default class AssetWorkerOuter<TEnv extends Env = Env>
|
||||
extends WorkerEntrypoint<TEnv>
|
||||
implements AssetWorkerMethods
|
||||
{
|
||||
private resolvedCohort: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Resolves and caches the cohort for this request. The cohort is
|
||||
* constant for the lifetime of the outer entrypoint instance, so
|
||||
* we only pay the RPC cost once even if multiple methods are called.
|
||||
*/
|
||||
private async getCohort(): Promise<string | null> {
|
||||
if (this.resolvedCohort === undefined) {
|
||||
// TODO: Hardcoding temporarily for latency testing.
|
||||
// this.resolvedCohort = await lookupCohort(
|
||||
// this.env,
|
||||
// this.env.CONFIG?.account_id
|
||||
// );
|
||||
this.resolvedCohort = "ent";
|
||||
}
|
||||
return this.resolvedCohort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the inner entrypoint from ctx.exports to forward requests to.
|
||||
* When a cohort is provided, the runtime routes the inner entrypoint
|
||||
* to the version assigned to that cohort in the current deployment.
|
||||
*/
|
||||
private getInnerEntrypoint(_cohort?: string | null): AssetWorkerMethods {
|
||||
const loopbackCtx = this.ctx as AssetWorkerContext;
|
||||
const entrypoint = loopbackCtx.exports?.AssetWorkerInner;
|
||||
if (entrypoint === undefined) {
|
||||
throw new Error(
|
||||
"AssetWorkerInner not found on ctx.exports. " +
|
||||
"Ensure enable_ctx_exports compatibility flag is set and " +
|
||||
"AssetWorkerInner is exported from the worker module."
|
||||
);
|
||||
}
|
||||
return entrypoint({
|
||||
props: { traceContext: this.env.JAEGER.getSpanContext() },
|
||||
// TODO: Hardcoding temporarily for latency testing.
|
||||
// ...(cohort ? { version: { cohort } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
override async fetch(request: Request): Promise<Response> {
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
let sentry: ReturnType<typeof setupSentry> | undefined;
|
||||
const analytics = new Analytics(this.env.ANALYTICS);
|
||||
const performance = new PerformanceTimer(this.env.UNSAFE_PERFORMANCE);
|
||||
const startTimeMs = performance.now();
|
||||
try {
|
||||
if (
|
||||
this.env.COLO_METADATA &&
|
||||
this.env.VERSION_METADATA &&
|
||||
this.env.CONFIG
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
analytics.setData({
|
||||
accountId: this.env.CONFIG.account_id,
|
||||
scriptId: this.env.CONFIG.script_id,
|
||||
coloId: this.env.COLO_METADATA.coloId,
|
||||
metalId: this.env.COLO_METADATA.metalId,
|
||||
coloTier: this.env.COLO_METADATA.coloTier,
|
||||
coloRegion: this.env.COLO_METADATA.coloRegion,
|
||||
hostname: url.hostname,
|
||||
version: this.env.VERSION_METADATA.tag,
|
||||
entrypoint: EntrypointType.Outer,
|
||||
});
|
||||
}
|
||||
sentry = setupSentry(
|
||||
request,
|
||||
this.ctx,
|
||||
this.env.SENTRY_DSN,
|
||||
this.env.SENTRY_ACCESS_CLIENT_ID,
|
||||
this.env.SENTRY_ACCESS_CLIENT_SECRET,
|
||||
this.env.COLO_METADATA,
|
||||
this.env.VERSION_METADATA,
|
||||
this.env.CONFIG?.account_id,
|
||||
this.env.CONFIG?.script_id
|
||||
);
|
||||
|
||||
const cohort = await this.getCohort();
|
||||
analytics.setData({ cohort: cohort ?? "unknown" });
|
||||
|
||||
const response = await this.getInnerEntrypoint(cohort).fetch(request);
|
||||
analytics.setData({ status: response.status });
|
||||
if (response.status >= 500) {
|
||||
analytics.setData({ error: "inner entrypoint error" });
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
analytics.setData({ status: 500 });
|
||||
return handleError(sentry, analytics, err);
|
||||
} finally {
|
||||
submitMetrics(analytics, performance, startTimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
async unstable_canFetch(request: Request): Promise<boolean> {
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
const cohort = await this.getCohort();
|
||||
return this.getInnerEntrypoint(cohort).unstable_canFetch(request);
|
||||
}
|
||||
|
||||
async unstable_getByETag(
|
||||
eTag: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult> {
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
const cohort = await this.getCohort();
|
||||
return this.getInnerEntrypoint(cohort).unstable_getByETag(eTag, request);
|
||||
}
|
||||
|
||||
async unstable_getByPathname(
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult | null> {
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
const cohort = await this.getCohort();
|
||||
return this.getInnerEntrypoint(cohort).unstable_getByPathname(
|
||||
pathname,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
async unstable_exists(
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<string | null> {
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
const cohort = await this.getCohort();
|
||||
return this.getInnerEntrypoint(cohort).unstable_exists(pathname, request);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 4: INNER ENTRYPOINT (AssetWorkerInner)
|
||||
// ============================================================
|
||||
|
||||
/*
|
||||
* AssetWorkerInner contains the actual implementation logic for all asset
|
||||
* worker methods. In production, it is instantiated by AssetWorkerOuter via
|
||||
* ctx.exports. For local development, tools such as the Vite plugin can
|
||||
* subclass AssetWorkerInner directly to override methods like unstable_exists
|
||||
* and unstable_getByETag with dev-server-backed implementations.
|
||||
*/
|
||||
export class AssetWorkerInner<TEnv extends Env = Env>
|
||||
extends WorkerEntrypoint<TEnv>
|
||||
implements AssetWorkerMethods
|
||||
{
|
||||
override async fetch(request: Request): Promise<Response> {
|
||||
// TODO: Mock this with Miniflare
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
const loopbackCtx = this.ctx as AssetWorkerContext;
|
||||
const traceContext = loopbackCtx.props?.traceContext ?? null;
|
||||
const cohort = this.ctx.version?.cohort;
|
||||
|
||||
const response = await this.env.JAEGER.runWithSpanContext(
|
||||
traceContext,
|
||||
() =>
|
||||
runFetchRequest(
|
||||
request,
|
||||
this.env,
|
||||
this.ctx,
|
||||
this.unstable_exists.bind(this),
|
||||
this.unstable_getByETag.bind(this),
|
||||
cohort
|
||||
)
|
||||
);
|
||||
|
||||
if (response instanceof Response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
throw new Error("AssetWorkerInner fetch returned non-Response value.");
|
||||
}
|
||||
|
||||
async unstable_canFetch(request: Request): Promise<boolean> {
|
||||
// TODO: Mock this with Miniflare
|
||||
this.env.JAEGER ??= mockJaegerBinding();
|
||||
|
||||
return canFetch(
|
||||
request,
|
||||
this.env,
|
||||
normalizeConfiguration(this.env.CONFIG),
|
||||
this.unstable_exists.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
async unstable_getByETag(
|
||||
eTag: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult> {
|
||||
return unstableGetByETagImpl(this.env, eTag, request);
|
||||
}
|
||||
|
||||
async unstable_getByPathname(
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<GetByETagResult | null> {
|
||||
return unstableGetByPathnameImpl(
|
||||
this.env,
|
||||
this.unstable_exists.bind(this),
|
||||
this.unstable_getByETag.bind(this),
|
||||
pathname,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
async unstable_exists(
|
||||
pathname: string,
|
||||
request?: Request
|
||||
): Promise<string | null> {
|
||||
return unstableExistsImpl(this.env, pathname, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, vi } from "vitest";
|
||||
import { Analytics, EntrypointType } from "../src/analytics";
|
||||
import type { ReadyAnalyticsEvent } from "../src/types";
|
||||
|
||||
describe("[Asset Worker] Analytics", () => {
|
||||
function captureEvent(): {
|
||||
analytics: Analytics;
|
||||
getEvent: () => ReadyAnalyticsEvent | undefined;
|
||||
} {
|
||||
let captured: ReadyAnalyticsEvent | undefined;
|
||||
const mockReadyAnalytics = {
|
||||
logEvent: vi.fn((event: ReadyAnalyticsEvent) => {
|
||||
captured = event;
|
||||
}),
|
||||
};
|
||||
return {
|
||||
analytics: new Analytics(mockReadyAnalytics),
|
||||
getEvent: () => captured,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EntrypointType enum", () => {
|
||||
it("has expected numeric values", ({ expect }) => {
|
||||
expect(EntrypointType.Outer).toBe(0);
|
||||
expect(EntrypointType.Inner).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entrypoint discriminator (double7)", () => {
|
||||
it("writes -1 when entrypoint is not set", ({ expect }) => {
|
||||
const { analytics, getEvent } = captureEvent();
|
||||
analytics.write();
|
||||
|
||||
const event = getEvent();
|
||||
expect(event?.doubles?.[6]).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full event shape", () => {
|
||||
it("preserves all existing fields alongside new ones", ({ expect }) => {
|
||||
const { analytics, getEvent } = captureEvent();
|
||||
analytics.setData({
|
||||
accountId: 123,
|
||||
scriptId: 456,
|
||||
requestTime: 50,
|
||||
coloId: 1,
|
||||
metalId: 2,
|
||||
coloTier: 3,
|
||||
status: 200,
|
||||
entrypoint: EntrypointType.Inner,
|
||||
hostname: "example.com",
|
||||
userAgent: "test-agent",
|
||||
htmlHandling: "auto-trailing-slash",
|
||||
notFoundHandling: "none",
|
||||
error: "",
|
||||
version: "abc123",
|
||||
coloRegion: "WEUR",
|
||||
cacheStatus: "HIT",
|
||||
cohort: "free",
|
||||
});
|
||||
analytics.write();
|
||||
|
||||
const event = getEvent();
|
||||
// Doubles
|
||||
expect(event?.doubles?.[0]).toBe(50); // requestTime
|
||||
expect(event?.doubles?.[1]).toBe(1); // coloId
|
||||
expect(event?.doubles?.[2]).toBe(2); // metalId
|
||||
expect(event?.doubles?.[3]).toBe(3); // coloTier
|
||||
expect(event?.doubles?.[4]).toBe(200); // status
|
||||
// double6 is compatibilityFlags bitmask, defaults to 0
|
||||
expect(event?.doubles?.[5]).toBe(0);
|
||||
expect(event?.doubles?.[6]).toBe(EntrypointType.Inner); // entrypoint
|
||||
|
||||
// Blobs
|
||||
expect(event?.blobs?.[0]).toBe("example.com"); // hostname
|
||||
expect(event?.blobs?.[1]).toBe("test-agent"); // userAgent
|
||||
expect(event?.blobs?.[2]).toBe("auto-trailing-slash"); // htmlHandling
|
||||
expect(event?.blobs?.[3]).toBe("none"); // notFoundHandling
|
||||
expect(event?.blobs?.[4]).toBe(""); // error
|
||||
expect(event?.blobs?.[5]).toBe("abc123"); // version
|
||||
expect(event?.blobs?.[6]).toBe("WEUR"); // coloRegion
|
||||
expect(event?.blobs?.[7]).toBe("HIT"); // cacheStatus
|
||||
expect(event?.blobs?.[8]).toBe("free"); // cohort
|
||||
|
||||
// Indexes
|
||||
expect(event?.accountId).toBe(123);
|
||||
expect(event?.indexId).toBe("456");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
CONTENT_HASH_OFFSET,
|
||||
ENTRY_SIZE,
|
||||
HEADER_SIZE,
|
||||
PATH_HASH_OFFSET,
|
||||
PATH_HASH_SIZE,
|
||||
} from "../../utils/constants";
|
||||
import AssetManifestFixture from "../fixtures/AssetManifest.bin";
|
||||
import { binarySearch, hashPath } from "../src/assets-manifest";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
async function SHA_256(value: string, length: number) {
|
||||
const data = encoder.encode(value);
|
||||
const hashBuffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
data.buffer as ArrayBuffer
|
||||
);
|
||||
return new Uint8Array(hashBuffer, 0, length);
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string) {
|
||||
if (!hex.match(/^([0-9a-f]{2})+$/gi)) {
|
||||
throw new TypeError(`Invalid byte string: ${hex}`);
|
||||
}
|
||||
|
||||
return new Uint8Array(
|
||||
hex.match(/[0-9a-f]{2}/gi)?.map((b) => parseInt(b, 16)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
const encode = async (
|
||||
assetEntries: { path: string; contentHash: string }[]
|
||||
) => {
|
||||
const entries = await Promise.all(
|
||||
assetEntries.map(async (entry) => ({
|
||||
path: entry.path,
|
||||
contentHash: entry.contentHash,
|
||||
pathHashBytes: await SHA_256(entry.path, PATH_HASH_SIZE),
|
||||
}))
|
||||
);
|
||||
entries.sort((a, b) => compare(a.pathHashBytes, b.pathHashBytes));
|
||||
|
||||
const assetManifestBytes = new Uint8Array(
|
||||
HEADER_SIZE + entries.length * ENTRY_SIZE
|
||||
);
|
||||
|
||||
for (const [i, { pathHashBytes, contentHash }] of entries.entries()) {
|
||||
const contentHashBytes = hexToBytes(contentHash);
|
||||
const entryOffset = HEADER_SIZE + i * ENTRY_SIZE;
|
||||
|
||||
assetManifestBytes.set(pathHashBytes, entryOffset + PATH_HASH_OFFSET);
|
||||
assetManifestBytes.set(contentHashBytes, entryOffset + CONTENT_HASH_OFFSET);
|
||||
}
|
||||
|
||||
return assetManifestBytes.buffer;
|
||||
};
|
||||
|
||||
describe("encode()", () => {
|
||||
it("works", async ({ expect }) => {
|
||||
const snapshotValue = new Uint8Array(AssetManifestFixture);
|
||||
|
||||
const computedValue = new Uint8Array(
|
||||
await encode([
|
||||
{
|
||||
path: "/path1",
|
||||
contentHash: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
path: "/path2",
|
||||
contentHash: "1123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
path: "/path3",
|
||||
contentHash: "ABCDEF01231230123131231FDFFEDFDF",
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(compare(computedValue, snapshotValue)).toBe(0);
|
||||
|
||||
const invalidContentHashValue = new Uint8Array(
|
||||
await encode([
|
||||
{
|
||||
path: "/path1",
|
||||
contentHash: "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE",
|
||||
},
|
||||
{
|
||||
path: "/path2",
|
||||
contentHash: "1123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
path: "/path3",
|
||||
contentHash: "ABCDEF01231230123131231FDFFEDFDF",
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(compare(invalidContentHashValue, snapshotValue)).not.toBe(0);
|
||||
|
||||
const invalidPathValue = new Uint8Array(
|
||||
await encode([
|
||||
{
|
||||
path: "/path123",
|
||||
contentHash: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
path: "/path2",
|
||||
contentHash: "1123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
path: "/path3",
|
||||
contentHash: "ABCDEF01231230123131231FDFFEDFDF",
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(compare(invalidPathValue, snapshotValue)).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const makePathForId = (id: number) => `/path${id}`;
|
||||
|
||||
const makeManifestOfLength = async (length: number) => {
|
||||
const entries = new Array(length).fill(undefined).map((_, i) => ({
|
||||
path: makePathForId(i),
|
||||
contentHash: String(i).padEnd(32, "f"),
|
||||
}));
|
||||
return { entries, manifest: await encode(entries) };
|
||||
};
|
||||
|
||||
describe("search methods", () => {
|
||||
describe("binary search", () => {
|
||||
it("doesn't error for an empty manifest", async ({ expect }) => {
|
||||
const { manifest } = await makeManifestOfLength(0);
|
||||
const foundEntry = binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
await hashPath("/path")
|
||||
);
|
||||
expect(foundEntry).toBe(false);
|
||||
});
|
||||
|
||||
it("works for a single entry manifest", async ({ expect }) => {
|
||||
const { manifest, entries } = await makeManifestOfLength(1);
|
||||
for (const searchEntry of entries) {
|
||||
const path = await hashPath(searchEntry.path);
|
||||
const foundEntry = binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
path
|
||||
) as Uint8Array;
|
||||
expect(foundEntry).not.toBe(false);
|
||||
expect(foundEntry).toEqual(hexToBytes(searchEntry.contentHash));
|
||||
}
|
||||
});
|
||||
|
||||
it("works for a two entry manifest", async ({ expect }) => {
|
||||
const { manifest, entries } = await makeManifestOfLength(2);
|
||||
for (const searchEntry of entries) {
|
||||
const path = await hashPath(searchEntry.path);
|
||||
const foundEntry = binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
path
|
||||
) as Uint8Array;
|
||||
expect(foundEntry).not.toBe(false);
|
||||
expect(foundEntry).toEqual(hexToBytes(searchEntry.contentHash));
|
||||
}
|
||||
});
|
||||
|
||||
it("works for a three entry manifest", async ({ expect }) => {
|
||||
const { manifest, entries } = await makeManifestOfLength(3);
|
||||
for (const searchEntry of entries) {
|
||||
const path = await hashPath(searchEntry.path);
|
||||
const foundEntry = binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
path
|
||||
) as Uint8Array;
|
||||
expect(foundEntry).not.toBe(false);
|
||||
expect(foundEntry).toEqual(hexToBytes(searchEntry.contentHash));
|
||||
}
|
||||
});
|
||||
|
||||
it("works for a 20,000 entry manifest", async ({ expect }) => {
|
||||
const { manifest, entries } = await makeManifestOfLength(20_000);
|
||||
for (const searchEntry of entries) {
|
||||
const path = await hashPath(searchEntry.path);
|
||||
const foundEntry = binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
path
|
||||
) as Uint8Array;
|
||||
expect(foundEntry).not.toBe(false);
|
||||
expect(foundEntry).toEqual(hexToBytes(searchEntry.contentHash));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for non-existent paths", async ({ expect }) => {
|
||||
const { manifest } = await makeManifestOfLength(10);
|
||||
for (const searchEntry of [
|
||||
new Uint8Array(PATH_HASH_SIZE),
|
||||
new Uint8Array(PATH_HASH_SIZE).fill(127),
|
||||
new Uint8Array(PATH_HASH_SIZE).fill(255),
|
||||
]) {
|
||||
const foundEntry = binarySearch(new Uint8Array(manifest), searchEntry);
|
||||
expect(foundEntry).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws an error if the search value is longer than the current value", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { manifest } = await makeManifestOfLength(10);
|
||||
expect(() => {
|
||||
binarySearch(
|
||||
new Uint8Array(manifest),
|
||||
new Uint8Array(PATH_HASH_SIZE + 1).fill(127)
|
||||
);
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`[TypeError: Search value should have a length of ${PATH_HASH_SIZE}]`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Compare two Uint8Array values
|
||||
* @param a First array
|
||||
* @param b Second array
|
||||
* @returns -1 if a < b, 1 if a > b, 0 if equal
|
||||
*/
|
||||
function compare(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.byteLength < b.byteLength) {
|
||||
return -1;
|
||||
}
|
||||
if (a.byteLength > b.byteLength) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (const [i, v] of a.entries()) {
|
||||
const bVal = b[i] as number;
|
||||
if (v < bVal) {
|
||||
return -1;
|
||||
}
|
||||
if (v > bVal) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, vi } from "vitest";
|
||||
import { COHORT_LOOKUP_TIMEOUT_MS, lookupCohort } from "../src/worker";
|
||||
import type { Env } from "../src/worker";
|
||||
import type { AccountCohortQuerierBinding } from "../worker-configuration";
|
||||
|
||||
function makeEnv(
|
||||
querier?: Partial<AccountCohortQuerierBinding>
|
||||
): Pick<Env, "ACCOUNT_COHORT_QUERIER"> {
|
||||
return {
|
||||
ACCOUNT_COHORT_QUERIER: querier as AccountCohortQuerierBinding | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("[Asset Worker] lookupCohort", () => {
|
||||
it("calls querier with account ID as string", async ({ expect }) => {
|
||||
const lookupMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: "ent",
|
||||
meta: { workersVersion: "test" },
|
||||
});
|
||||
|
||||
const result = await lookupCohort(
|
||||
makeEnv({ lookupAccountCohort: lookupMock }) as Env,
|
||||
42
|
||||
);
|
||||
|
||||
expect(result).toBe("ent");
|
||||
expect(lookupMock).toHaveBeenCalledWith("42");
|
||||
});
|
||||
|
||||
it("returns null when result is ok:false", async ({ expect }) => {
|
||||
const result = await lookupCohort(
|
||||
makeEnv({
|
||||
lookupAccountCohort: () =>
|
||||
Promise.resolve({
|
||||
ok: false as const,
|
||||
errors: [
|
||||
{ name: "Error", message: "invalid account", code: "ERR" },
|
||||
],
|
||||
}),
|
||||
}) as Env,
|
||||
42
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when result is null", async ({ expect }) => {
|
||||
const result = await lookupCohort(
|
||||
makeEnv({
|
||||
lookupAccountCohort: () =>
|
||||
Promise.resolve({
|
||||
ok: true as const,
|
||||
result: null,
|
||||
meta: { workersVersion: "test" },
|
||||
}),
|
||||
}) as Env,
|
||||
42
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("times out after COHORT_LOOKUP_TIMEOUT_MS", async ({ expect }) => {
|
||||
const result = await lookupCohort(
|
||||
makeEnv({
|
||||
lookupAccountCohort: () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
ok: true as const,
|
||||
result: "ent",
|
||||
meta: { workersVersion: "test" },
|
||||
}),
|
||||
COHORT_LOOKUP_TIMEOUT_MS * 2
|
||||
);
|
||||
}),
|
||||
}) as Env,
|
||||
42
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, test } from "vitest";
|
||||
import { resolveCompatibilityOptions } from "../src/compatibility-flags";
|
||||
|
||||
describe("resolveCompatibilityOptions", () => {
|
||||
test("it does not interfere with existing flags", ({ expect }) => {
|
||||
expect(resolveCompatibilityOptions({ compatibility_flags: ["foo", "bar"] }))
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"compatibilityDate": "2021-11-02",
|
||||
"compatibilityFlags": [
|
||||
"foo",
|
||||
"bar",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("it opts you into new flags if you have a future date", ({ expect }) => {
|
||||
const { compatibilityDate, compatibilityFlags } =
|
||||
resolveCompatibilityOptions({
|
||||
compatibility_flags: ["foo", "bar"],
|
||||
compatibility_date: "2099-12-12",
|
||||
});
|
||||
|
||||
expect(compatibilityDate).toEqual("2099-12-12");
|
||||
expect(compatibilityFlags).toContain("foo");
|
||||
expect(compatibilityFlags).toContain("bar");
|
||||
expect(compatibilityFlags).toContain(
|
||||
"assets_navigation_prefers_asset_serving"
|
||||
);
|
||||
});
|
||||
|
||||
test("it doesn't double you up if you've already opted into new flags and have a future date", ({
|
||||
expect,
|
||||
}) => {
|
||||
const { compatibilityDate, compatibilityFlags } =
|
||||
resolveCompatibilityOptions({
|
||||
compatibility_flags: [
|
||||
"foo",
|
||||
"bar",
|
||||
"assets_navigation_prefers_asset_serving",
|
||||
],
|
||||
compatibility_date: "2099-12-12",
|
||||
});
|
||||
|
||||
expect(compatibilityDate).toEqual("2099-12-12");
|
||||
expect(compatibilityFlags).toContain("foo");
|
||||
expect(compatibilityFlags).toContain("bar");
|
||||
expect(
|
||||
compatibilityFlags.filter(
|
||||
(flag) => flag === "assets_navigation_prefers_asset_serving"
|
||||
).length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("it doesn't opt you into new flags if you have a future date and have disabled it", ({
|
||||
expect,
|
||||
}) => {
|
||||
const { compatibilityDate, compatibilityFlags } =
|
||||
resolveCompatibilityOptions({
|
||||
compatibility_flags: ["foo", "bar", "assets_navigation_has_no_effect"],
|
||||
compatibility_date: "2099-12-12",
|
||||
});
|
||||
|
||||
expect(compatibilityDate).toEqual("2099-12-12");
|
||||
expect(compatibilityFlags).toContain("foo");
|
||||
expect(compatibilityFlags).toContain("bar");
|
||||
expect(compatibilityFlags).not.toContain(
|
||||
"assets_navigation_prefers_asset_serving"
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import { afterEach, assert, beforeEach, describe, it, vi } from "vitest";
|
||||
import { getAssetWithMetadataFromKV } from "../src/utils/kv";
|
||||
import type { AssetMetadata } from "../src/utils/kv";
|
||||
import type { MockInstance } from "vitest";
|
||||
|
||||
describe("[Asset Worker] Fetching assets from KV", () => {
|
||||
describe("getAssetWithMetadataFromKV()", () => {
|
||||
let mockKVNamespace: KVNamespace;
|
||||
let spy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockKVNamespace = {
|
||||
getWithMetadata: () => Promise.resolve(),
|
||||
} as unknown as KVNamespace;
|
||||
|
||||
spy = vi.spyOn(mockKVNamespace, "getWithMetadata");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return the asset value and metadata, if asset was found in the KV store", async ({
|
||||
expect,
|
||||
}) => {
|
||||
spy.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
value: "<html>Hello world</html>",
|
||||
metadata: {
|
||||
contentType: "text/html",
|
||||
},
|
||||
}) as unknown as Promise<
|
||||
KVNamespaceGetWithMetadataResult<ReadableStream, AssetMetadata>
|
||||
>
|
||||
);
|
||||
|
||||
const asset = await getAssetWithMetadataFromKV(mockKVNamespace, "abcd");
|
||||
assert(asset);
|
||||
expect(asset.value).toEqual("<html>Hello world</html>");
|
||||
expect(asset.metadata).toEqual({
|
||||
contentType: "text/html",
|
||||
});
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should throw an error if something went wrong while fetching the asset", async ({
|
||||
expect,
|
||||
}) => {
|
||||
spy.mockReturnValue(Promise.reject("Oeps! Something went wrong"));
|
||||
|
||||
await expect(() =>
|
||||
getAssetWithMetadataFromKV(mockKVNamespace, "abcd")
|
||||
).rejects.toThrow("KV GET abcd failed.");
|
||||
});
|
||||
|
||||
it("should retry once by default if something went wrong while fetching the asset", async ({
|
||||
expect,
|
||||
}) => {
|
||||
spy.mockReturnValue(Promise.reject("Oeps! Something went wrong"));
|
||||
|
||||
await expect(() =>
|
||||
getAssetWithMetadataFromKV(mockKVNamespace, "abcd")
|
||||
).rejects.toThrow("KV GET abcd failed.");
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should support custom number of retries", async ({ expect }) => {
|
||||
spy.mockReturnValue(Promise.reject("Oeps! Something went wrong"));
|
||||
|
||||
await expect(() =>
|
||||
getAssetWithMetadataFromKV(mockKVNamespace, "abcd", undefined, 2)
|
||||
).rejects.toThrow("KV GET abcd failed.");
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should inject message with error", async ({ expect }) => {
|
||||
spy.mockReturnValue(
|
||||
Promise.reject(new Error("Oeps! Something went wrong"))
|
||||
);
|
||||
|
||||
await expect(() =>
|
||||
getAssetWithMetadataFromKV(mockKVNamespace, "abcd")
|
||||
).rejects.toThrow("KV GET abcd failed: Oeps! Something went wrong");
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should retry on 404 and cache with shorter ttl", async ({ expect }) => {
|
||||
let attempts = 0;
|
||||
spy.mockImplementation(() => {
|
||||
if (attempts++ === 0) {
|
||||
return Promise.resolve({
|
||||
value: null,
|
||||
}) as unknown as Promise<
|
||||
KVNamespaceGetWithMetadataResult<ReadableStream, AssetMetadata>
|
||||
>;
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
value: "<html>Hello world</html>",
|
||||
metadata: {
|
||||
contentType: "text/html",
|
||||
},
|
||||
}) as unknown as Promise<
|
||||
KVNamespaceGetWithMetadataResult<ReadableStream, AssetMetadata>
|
||||
>;
|
||||
}
|
||||
});
|
||||
|
||||
const asset = await getAssetWithMetadataFromKV(mockKVNamespace, "abcd");
|
||||
expect(asset?.value).toBeTruthy();
|
||||
// Once for the initial call, once for the 404
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
generateRulesMatcher,
|
||||
generateStaticRoutingRuleMatcher,
|
||||
replacer,
|
||||
} from "../src/utils/rules-engine";
|
||||
|
||||
describe("rules engine", () => {
|
||||
test("it should match simple pathname hosts", ({ expect }) => {
|
||||
const matcher = generateRulesMatcher({ "/test": 1, "/some%20page": 2 });
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/test") })
|
||||
).toEqual([1]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/some page") })
|
||||
).toEqual([2]);
|
||||
});
|
||||
|
||||
test("it should match cross-host requests", ({ expect }) => {
|
||||
const matcher = generateRulesMatcher({
|
||||
"/test": 1,
|
||||
"/anotherpage": 2,
|
||||
"https://custom.domain/test": 3,
|
||||
"https://another.domain/test": 4,
|
||||
"//fake.host/actually-a-path": 5,
|
||||
});
|
||||
expect(
|
||||
matcher({ request: new Request("https://custom.domain/test") })
|
||||
).toEqual([1, 3]);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("https://example.com//fake.host/actually-a-path"),
|
||||
})
|
||||
).toEqual([5]);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("other://custom.domain:123/test"),
|
||||
})
|
||||
).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test("it should escape funky rules", ({ expect }) => {
|
||||
const matcher = generateRulesMatcher({
|
||||
"/$~.%20/!+-/[bo|%7Bo%7D]...()": 1,
|
||||
});
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("https://example.com/$~. \\!+-/[bo|{o}]...()"),
|
||||
})
|
||||
).toEqual([1]);
|
||||
});
|
||||
|
||||
test("it should support splats and placeholders", ({ expect }) => {
|
||||
const matcher = generateRulesMatcher(
|
||||
{
|
||||
"/foo/test/*": "1/:splat",
|
||||
"/foo/*": "2/:splat",
|
||||
"/blog/:code/:name": "3/:name/:code",
|
||||
"/blog/*": "4/:splat",
|
||||
"https://:subdomain.pages.:tld/*": "5/:subdomain/:tld/:splat",
|
||||
"https://next.:subdomain.pages.:tld/*": "6/:subdomain/",
|
||||
},
|
||||
(match, replacements) => replacer(match, replacements)
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/test/yes") })
|
||||
).toEqual(["1/yes", "2/test/yes"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/test/") })
|
||||
).toEqual(["1/", "2/test/"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/test") })
|
||||
).toEqual(["2/test"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/") })
|
||||
).toEqual(["2/"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo") })
|
||||
).toEqual([]);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("https://example.com/blog/123/tricycle"),
|
||||
})
|
||||
).toEqual(["3/tricycle/123", "4/123/tricycle"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://my.pages.dev/magic") })
|
||||
).toEqual(["5/my/dev/magic"]);
|
||||
expect(
|
||||
matcher({ request: new Request("https://next.my.pages.dev/magic") })
|
||||
).toEqual(["6/my/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replacer", () => {
|
||||
test("should replace splats", ({ expect }) => {
|
||||
expect(replacer("/blog/:splat", { splat: "look/a/value" })).toEqual(
|
||||
"/blog/look/a/value"
|
||||
);
|
||||
});
|
||||
|
||||
test("should replace placeholders", ({ expect }) => {
|
||||
expect(
|
||||
replacer("/:code/:name.jpg", { name: "tricycle", code: "123" })
|
||||
).toEqual("/123/tricycle.jpg");
|
||||
});
|
||||
|
||||
test("should replace splats and placeholders", ({ expect }) => {
|
||||
expect(
|
||||
replacer("/:code/:splat", { splat: "tricycle/images", code: "123" })
|
||||
).toEqual("/123/tricycle/images");
|
||||
});
|
||||
|
||||
test("should replace all instances of placeholders", ({ expect }) => {
|
||||
expect(
|
||||
replacer(
|
||||
"Link: </assets/:value/main.js>; rel=preload; as=script, </assets/:value/lang.js>; rel=preload; as=script",
|
||||
{ value: "js" }
|
||||
)
|
||||
).toEqual(
|
||||
"Link: </assets/js/main.js>; rel=preload; as=script, </assets/js/lang.js>; rel=preload; as=script"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static routing rules", () => {
|
||||
test("should return true for a request that matches", ({ expect }) => {
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/some/path"])({
|
||||
request: new Request("https://site.com/some/path"),
|
||||
})
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/some/*"])({
|
||||
request: new Request("https://site.com/some/path"),
|
||||
})
|
||||
).toEqual(true);
|
||||
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/no/match", "/some/*"])({
|
||||
request: new Request("https://site.com/some/path"),
|
||||
})
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test("should return false for a request that does not match", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/some/path"])({
|
||||
request: new Request("https://site.com"),
|
||||
})
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/some/*"])({
|
||||
request: new Request("https://site.com/path"),
|
||||
})
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher(["/some/path", "/other/path"])({
|
||||
request: new Request("https://site.com/path"),
|
||||
})
|
||||
).toEqual(false);
|
||||
|
||||
expect(
|
||||
generateStaticRoutingRuleMatcher([])({
|
||||
request: new Request("https://site.com/some/path"),
|
||||
})
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
test("should ignore regex characters other than a glob", ({ expect }) => {
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/"]);
|
||||
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("http://example.com") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/?foo=bar") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(false);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/foo"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/baz") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/baz/foo") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foobar") })
|
||||
).toEqual(false);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/:placeholder"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/:placeholder") })
|
||||
).toEqual(true);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/foo*"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foobar") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/baz") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/baz/foo") })
|
||||
).toEqual(false);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/*.html"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo.html") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo/bar.html") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo/bar") })
|
||||
).toEqual(false);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/login/*"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/login/foo") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example2.com/login/foo") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo/login/foo") })
|
||||
).toEqual(false);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("http://example.com/foo?bar=baz/login/foo"),
|
||||
})
|
||||
).toEqual(false);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["/*"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://foo.example.com/bar") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("http://example2.com/foo.example.com/baz"),
|
||||
})
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("http://example2.com/?q=foo.example.com/baz"),
|
||||
})
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo.html") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar.html") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foobar") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("http://example.com") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("https://example.com") })).toEqual(
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const matcher = generateStaticRoutingRuleMatcher(["*/*"]);
|
||||
expect(
|
||||
matcher({ request: new Request("http://foo.example.com/bar") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("http://example2.com/foo.example.com/baz"),
|
||||
})
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({
|
||||
request: new Request("http://example2.com/?q=foo.example.com/baz"),
|
||||
})
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo.html") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar.html") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("http://example.com/foo") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foo/bar") })
|
||||
).toEqual(true);
|
||||
expect(
|
||||
matcher({ request: new Request("https://example.com/foobar") })
|
||||
).toEqual(true);
|
||||
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("http://example.com") })).toEqual(
|
||||
true
|
||||
);
|
||||
expect(matcher({ request: new Request("https://example.com") })).toEqual(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers/types"
|
||||
]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "*.bin" {
|
||||
const arrayBuffer: ArrayBuffer;
|
||||
export default arrayBuffer;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["@cloudflare/workers-types/experimental"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: {
|
||||
configPath: "./asset-worker/wrangler.jsonc",
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
reporters: ["default"],
|
||||
globals: true,
|
||||
testTimeout: 50_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Populates Cloudflare.Exports (the type of ctx.exports) with loopback
|
||||
// bindings derived from the main module's exports.
|
||||
declare namespace Cloudflare {
|
||||
interface GlobalProps {
|
||||
mainModule: typeof import("./src/worker");
|
||||
}
|
||||
}
|
||||
|
||||
// Adds `version` from the experimental `enable_version_api` compat flag.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- generic must match the original declaration for interface merging
|
||||
interface ExecutionContext<Props = unknown> {
|
||||
readonly version?: {
|
||||
cohort?: string;
|
||||
key?: string;
|
||||
override?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal RPC binding type for the AccountCohortQuerier entrypoint
|
||||
* in the account-services worker. Replace with the published type from
|
||||
* `@cloudflare/workers-toolbox-types` once available with bundled deps.
|
||||
*/
|
||||
export interface AccountCohortQuerierBinding {
|
||||
lookupAccountCohort(accountID: string): Promise<
|
||||
| { ok: true; result: string | null; meta: { workersVersion: string } }
|
||||
| {
|
||||
ok: false;
|
||||
errors: Array<{ name: string; message: string; code: string }>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Configuration file for the Asset Worker
|
||||
|
||||
Please note that wrangler has a dependency on this file, and will
|
||||
attempt to read it as part of setting up a new Miniflare instance
|
||||
in developemnt mode. We should ensure that any configuration changes
|
||||
to this file are persisted in wrangler as well, when necessary.
|
||||
(see packages/wrangler/src/dev/miniflare.ts -> buildMiniflareOptions())
|
||||
*/
|
||||
{
|
||||
"name": "asset-worker",
|
||||
"account_id": "0f1b8aa119a907021f659042f95ea9ba",
|
||||
"workers_dev": false,
|
||||
"main": "src/worker.ts",
|
||||
"compatibility_date": "2024-07-31",
|
||||
// nodejs_compat required when using @cloudflare/vitest-pool-workers
|
||||
// enable_ctx_exports enables ctx.exports loopback for the AssetWorkerInner
|
||||
"compatibility_flags": ["nodejs_compat", "enable_ctx_exports"],
|
||||
"unsafe": {
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_asset_worker",
|
||||
"networks": ["cf", "jdc"],
|
||||
},
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "assetConfig",
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_MANIFEST",
|
||||
"type": "param",
|
||||
"param": "assetManifest",
|
||||
"data_ref": true,
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_KV_NAMESPACE",
|
||||
"type": "internal_assets",
|
||||
},
|
||||
{
|
||||
"name": "workers-asset-worker",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
{
|
||||
"name": "ACCOUNT_COHORT_QUERIER",
|
||||
"type": "service",
|
||||
"service": "account-services-production",
|
||||
"entrypoint": "AccountCohortQuerier",
|
||||
"cross_account_grant": "workers-asset-worker",
|
||||
},
|
||||
],
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
},
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
"env": {
|
||||
"staging": {
|
||||
"name": "asset-worker-staging",
|
||||
// enable_version_api is set per-environment (not top-level) because
|
||||
// the local workerd in vitest-pool-workers does not support it yet.
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"enable_ctx_exports",
|
||||
"enable_version_api",
|
||||
],
|
||||
"unsafe": {
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_asset_worker",
|
||||
"networks": ["cf", "jdc"],
|
||||
},
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "assetConfig",
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_MANIFEST",
|
||||
"type": "param",
|
||||
"param": "assetManifest",
|
||||
"data_ref": true,
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_KV_NAMESPACE",
|
||||
"type": "internal_assets",
|
||||
},
|
||||
{
|
||||
"name": "workers-asset-worker-staging",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
{
|
||||
"name": "ACCOUNT_COHORT_QUERIER",
|
||||
"type": "service",
|
||||
"service": "account-services-staging",
|
||||
"entrypoint": "AccountCohortQuerier",
|
||||
"cross_account_grant": "workers-asset-worker-staging",
|
||||
},
|
||||
],
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "staging",
|
||||
},
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
},
|
||||
"fed-prod": {
|
||||
"name": "asset-worker-fed-prod",
|
||||
"account_id": "fca6c231dc1d1780777997c5bce5a5e3", // workers assets
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"enable_ctx_exports",
|
||||
"enable_version_api",
|
||||
],
|
||||
"vars": {
|
||||
"ENVIRONMENT": "fed-prod",
|
||||
},
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
"compliance_region": "fedramp_high",
|
||||
"unsafe": {
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_asset_worker",
|
||||
"networks": ["cf"],
|
||||
},
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "assetConfig",
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_MANIFEST",
|
||||
"type": "param",
|
||||
"param": "assetManifest",
|
||||
"data_ref": true,
|
||||
},
|
||||
{
|
||||
"name": "ASSETS_KV_NAMESPACE",
|
||||
"type": "internal_assets",
|
||||
},
|
||||
{
|
||||
"name": "workers-asset-worker",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
{
|
||||
"name": "ACCOUNT_COHORT_QUERIER",
|
||||
"type": "service",
|
||||
"service": "account-services-production",
|
||||
"entrypoint": "AccountCohortQuerier",
|
||||
"cross_account_grant": "workers-asset-worker",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./utils/constants";
|
||||
export * from "./utils/types";
|
||||
export * from "./utils/helpers";
|
||||
export * from "./utils/configuration/parseHeaders";
|
||||
export * from "./utils/configuration/parseRedirects";
|
||||
export * from "./utils/configuration/types";
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@cloudflare/workers-shared",
|
||||
"version": "0.19.7",
|
||||
"private": true,
|
||||
"description": "Package that is used at Cloudflare to power some internal features of Cloudflare Workers.",
|
||||
"keywords": [
|
||||
"cloudflare",
|
||||
"cloudflare workers",
|
||||
"workers"
|
||||
],
|
||||
"homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-shared#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cloudflare/workers-sdk/issues"
|
||||
},
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "wrangler@cloudflare.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cloudflare/workers-sdk.git",
|
||||
"directory": "packages/workers-shared"
|
||||
},
|
||||
"scripts": {
|
||||
"check:type": "pnpm run check:type:tests && tsc",
|
||||
"check:type:tests": "tsc -p ./asset-worker/tests/tsconfig.json && tsc -p ./router-worker/tests/tsconfig.json",
|
||||
"deploy": "pnpm run generate-sourcemaps && pnpm run deploy:router-worker && pnpm run deploy:asset-worker && pnpm run upload-sourcemaps",
|
||||
"deploy:asset-worker": "CLOUDFLARE_API_TOKEN=$WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN pnpx wrangler versions upload -c asset-worker/wrangler.jsonc --tag aw-$(node -r esbuild-register ./scripts/get-version-tag.ts)",
|
||||
"deploy:asset-worker-staging": "CLOUDFLARE_API_TOKEN=$WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN pnpx wrangler deploy -c asset-worker/wrangler.jsonc -e staging",
|
||||
"deploy:router-worker": "CLOUDFLARE_API_TOKEN=$WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN pnpx wrangler versions upload -c router-worker/wrangler.jsonc --tag rw-$(node -r esbuild-register ./scripts/get-version-tag.ts)",
|
||||
"deploy:router-worker-staging": "CLOUDFLARE_API_TOKEN=$WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN pnpx wrangler deploy -c router-worker/wrangler.jsonc -e staging",
|
||||
"deploy:staging": "pnpm run deploy:router-worker-staging && pnpm run deploy:asset-worker-staging",
|
||||
"generate-sourcemaps": "pnpm run generate-sourcemaps:asset-worker && pnpm generate-sourcemaps:router-worker",
|
||||
"generate-sourcemaps:asset-worker": "pnpx wrangler versions upload -c asset-worker/wrangler.jsonc --dry-run --outdir=./dist",
|
||||
"generate-sourcemaps:router-worker": "pnpx wrangler versions upload -c router-worker/wrangler.jsonc --dry-run --outdir=./dist",
|
||||
"test": "concurrently --group -n router-worker,asset-worker,utils \"pnpm run test:router-worker\" \"pnpm run test:asset-worker\" \"pnpm run test:utils\"",
|
||||
"test:asset-worker": "vitest -c asset-worker/vitest.config.mts --dir asset-worker",
|
||||
"test:ci": "concurrently --group -n router-worker,asset-worker,utils \"pnpm run test:router-worker --run\" \"pnpm run test:asset-worker --run\" \"pnpm run test:utils --run\"",
|
||||
"test:router-worker": "vitest -c router-worker/vitest.config.mts --dir router-worker",
|
||||
"test:utils": "vitest --dir utils --reporter=default",
|
||||
"upload-sourcemaps": "pnpm run upload-sourcemaps:asset-worker && pnpm run upload-sourcemaps:router-worker",
|
||||
"upload-sourcemaps:asset-worker": "node -r esbuild-register ./scripts/upload-sourcemaps.ts --worker asset-worker --tag aw-$(node -r esbuild-register ./scripts/get-version-tag.ts)",
|
||||
"upload-sourcemaps:router-worker": "node -r esbuild-register ./scripts/upload-sourcemaps.ts --worker router-worker --tag rw-$(node -r esbuild-register ./scripts/get-version-tag.ts)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "catalog:default",
|
||||
"@cloudflare/workers-tsconfig": "workspace:*",
|
||||
"@cloudflare/workers-types": "catalog:default",
|
||||
"@sentry/cli": "^2.37.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"esbuild": "catalog:default",
|
||||
"ignore": "^5.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"toucan-js": "4.0.0",
|
||||
"typescript": "catalog:default",
|
||||
"vitest": "catalog:default",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
},
|
||||
"workers-sdk": {
|
||||
"prerelease": true,
|
||||
"deploy": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# `router-worker`
|
||||
|
||||
The Router Worker is a [Cloudflare Worker](https://developers.cloudflare.com/workers/) that is responsible for routing between a user Worker and static assets in a Workers + Assets project.
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./src/worker";
|
||||
export { default } from "./src/worker";
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ReadyAnalytics } from "./types";
|
||||
|
||||
// This will allow us to make breaking changes to the analytic schema
|
||||
const VERSION = 1;
|
||||
|
||||
export enum STATIC_ROUTING_DECISION {
|
||||
NOT_PROVIDED = 0,
|
||||
NOT_ROUTED = 1,
|
||||
ROUTED = 2,
|
||||
}
|
||||
|
||||
export enum DISPATCH_TYPE {
|
||||
ASSETS = "asset",
|
||||
WORKER = "worker",
|
||||
}
|
||||
|
||||
// When adding new columns please update the schema
|
||||
type Data = {
|
||||
// -- Indexes --
|
||||
accountId?: number;
|
||||
scriptId?: number;
|
||||
|
||||
// -- Doubles --
|
||||
// double1 - The time it takes for the whole request to complete in milliseconds
|
||||
requestTime?: number;
|
||||
// double2 - Colo ID
|
||||
coloId?: number;
|
||||
// double3 - Metal ID
|
||||
metalId?: number;
|
||||
// double4 - Colo tier (e.g. tier 1, tier 2, tier 3)
|
||||
coloTier?: number;
|
||||
// double5 - Run user worker ahead of assets
|
||||
userWorkerAhead?: boolean;
|
||||
// double6 - Routing performed based on the _routes.json (if provided)
|
||||
staticRoutingDecision?: STATIC_ROUTING_DECISION;
|
||||
// double7 - Whether the request was blocked by abuse mitigation or not
|
||||
abuseMitigationBlocked?: boolean;
|
||||
// double8 - User worker invocation denied due to free tier limiting
|
||||
userWorkerFreeTierLimiting?: boolean;
|
||||
// double9 - The time it takes for the request to be handed off the Asset Worker or user Worker in milliseconds
|
||||
timeToDispatch?: number;
|
||||
|
||||
// -- Blobs --
|
||||
// blob1 - Hostname of the request
|
||||
hostname?: string;
|
||||
// blob2 - Dispatch type - what kind of thing did we dispatch
|
||||
dispatchtype?: DISPATCH_TYPE;
|
||||
// blob3 - Error message
|
||||
error?: string;
|
||||
// blob4 - The current version UUID of router-server
|
||||
version?: string;
|
||||
// blob5 - Region of the colo (e.g. WEUR)
|
||||
coloRegion?: string;
|
||||
// blob6 - URL for analysis
|
||||
abuseMitigationURLHost?: string;
|
||||
// blob7 - XSS detection href parameter value
|
||||
xssDetectionImageHref?: string;
|
||||
// blob8 - cdn-cgi backslash bypass attempt URL
|
||||
cdnCgiBackslashBypassUrl?: string;
|
||||
};
|
||||
|
||||
export class Analytics {
|
||||
private data: Data = {};
|
||||
private readyAnalytics?: ReadyAnalytics;
|
||||
private hasWritten: boolean = false;
|
||||
|
||||
constructor(readyAnalytics?: ReadyAnalytics) {
|
||||
this.readyAnalytics = readyAnalytics;
|
||||
}
|
||||
|
||||
setData(newData: Partial<Data>) {
|
||||
this.data = { ...this.data, ...newData };
|
||||
}
|
||||
|
||||
getData(key: keyof Data) {
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
write() {
|
||||
if (this.hasWritten) {
|
||||
// We've already written analytics, don't double send
|
||||
return;
|
||||
} else if (!this.readyAnalytics) {
|
||||
// Local environment, no-op
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasWritten = true;
|
||||
|
||||
this.readyAnalytics.logEvent({
|
||||
version: VERSION,
|
||||
accountId: this.data.accountId,
|
||||
indexId: this.data.scriptId?.toString(),
|
||||
doubles: [
|
||||
this.data.requestTime ?? -1, // double1
|
||||
this.data.coloId ?? -1, // double2
|
||||
this.data.metalId ?? -1, // double3
|
||||
this.data.coloTier ?? -1, // double4
|
||||
this.data.userWorkerAhead === undefined // double5
|
||||
? -1
|
||||
: Number(this.data.userWorkerAhead),
|
||||
this.data.staticRoutingDecision ?? STATIC_ROUTING_DECISION.NOT_PROVIDED, // double6
|
||||
this.data.abuseMitigationBlocked ? 1 : 0, // double7
|
||||
this.data.userWorkerFreeTierLimiting ? 1 : 0, // double8
|
||||
this.data.timeToDispatch ?? -1, // double9
|
||||
],
|
||||
blobs: [
|
||||
this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes
|
||||
this.data.dispatchtype, // blob2
|
||||
this.data.error?.substring(0, 256), // blob3 - trim to 256 bytes
|
||||
this.data.version, // blob4
|
||||
this.data.coloRegion, // blob5
|
||||
this.data.abuseMitigationURLHost, // blob6
|
||||
this.data.xssDetectionImageHref, // blob7
|
||||
this.data.cdnCgiBackslashBypassUrl?.substring(0, 256), // blob8 - trim to 256 bytes
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EyeballRouterConfig, RouterConfig } from "../../utils/types";
|
||||
|
||||
type RequiredEyeballRouterConfig = Required<Exclude<EyeballRouterConfig, null>>;
|
||||
|
||||
export const applyRouterConfigDefaults = (
|
||||
configuration?: RouterConfig
|
||||
): Required<RouterConfig> => {
|
||||
return {
|
||||
invoke_user_worker_ahead_of_assets:
|
||||
configuration?.invoke_user_worker_ahead_of_assets ?? false,
|
||||
has_user_worker: configuration?.has_user_worker ?? false,
|
||||
account_id: configuration?.account_id ?? -1,
|
||||
script_id: configuration?.script_id ?? -1,
|
||||
debug: configuration?.debug ?? false,
|
||||
static_routing: configuration?.static_routing ?? {
|
||||
user_worker: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const applyEyeballConfigDefaults = (
|
||||
eyeballConfiguration?: EyeballRouterConfig
|
||||
): RequiredEyeballRouterConfig => {
|
||||
return {
|
||||
limitedAssetsOnly: eyeballConfiguration?.limitedAssetsOnly ?? false,
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
export type Environment = "production" | "staging" | "fed-prod";
|
||||
|
||||
export interface ReadyAnalytics {
|
||||
logEvent: (e: ReadyAnalyticsEvent) => void;
|
||||
}
|
||||
|
||||
export interface ColoMetadata {
|
||||
metalId: number;
|
||||
coloId: number;
|
||||
coloRegion: string;
|
||||
coloTier: number;
|
||||
}
|
||||
|
||||
export interface ReadyAnalyticsEvent {
|
||||
accountId?: number;
|
||||
indexId?: string;
|
||||
version?: number;
|
||||
doubles?: (number | undefined)[];
|
||||
blobs?: (string | undefined)[];
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { generateStaticRoutingRuleMatcher } from "../../asset-worker/src/utils/rules-engine";
|
||||
import { PerformanceTimer } from "../../utils/performance";
|
||||
import { TemporaryRedirectResponse } from "../../utils/responses";
|
||||
import { setupSentry } from "../../utils/sentry";
|
||||
import { mockJaegerBinding } from "../../utils/tracing";
|
||||
import { Analytics, DISPATCH_TYPE, STATIC_ROUTING_DECISION } from "./analytics";
|
||||
import {
|
||||
applyEyeballConfigDefaults,
|
||||
applyRouterConfigDefaults,
|
||||
} from "./configuration";
|
||||
import { renderLimitedResponse } from "./limited-response";
|
||||
import type AssetWorker from "../../asset-worker";
|
||||
import type {
|
||||
EyeballRouterConfig,
|
||||
JaegerTracing,
|
||||
RouterConfig,
|
||||
UnsafePerformanceTimer,
|
||||
} from "../../utils/types";
|
||||
import type { ColoMetadata, Environment, ReadyAnalytics } from "./types";
|
||||
|
||||
export interface Env {
|
||||
ASSET_WORKER: Service<AssetWorker>;
|
||||
USER_WORKER: Fetcher;
|
||||
CONFIG: RouterConfig;
|
||||
EYEBALL_CONFIG: EyeballRouterConfig;
|
||||
|
||||
SENTRY_DSN: string;
|
||||
ENVIRONMENT: Environment;
|
||||
ANALYTICS: ReadyAnalytics;
|
||||
COLO_METADATA: ColoMetadata;
|
||||
UNSAFE_PERFORMANCE: UnsafePerformanceTimer;
|
||||
VERSION_METADATA: WorkerVersionMetadata;
|
||||
JAEGER: JaegerTracing;
|
||||
|
||||
SENTRY_ACCESS_CLIENT_ID: string;
|
||||
SENTRY_ACCESS_CLIENT_SECRET: string;
|
||||
}
|
||||
|
||||
type LoopbackExecutionContext = ExecutionContext & {
|
||||
exports: {
|
||||
RouterInnerEntrypoint(options: { props: Record<string, never> }): {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
// Router worker is typechecked both in this package and as a transitive
|
||||
// dependency during miniflare builds. In the miniflare type context,
|
||||
// Cloudflare.Exports may not include this worker's mainModule mapping from
|
||||
// worker-configuration.d.ts, so `ctx.exports.RouterInnerEntrypoint` does not
|
||||
// resolve structurally. Keep this narrow cast so loopback typechecks in both
|
||||
// compilation contexts without changing runtime behavior.
|
||||
const loopbackCtx = ctx as LoopbackExecutionContext;
|
||||
|
||||
const analytics = new Analytics(env.ANALYTICS);
|
||||
let inner;
|
||||
try {
|
||||
if (env.COLO_METADATA && env.VERSION_METADATA && env.CONFIG) {
|
||||
const url = new URL(request.url);
|
||||
analytics.setData({
|
||||
accountId: env.CONFIG.account_id,
|
||||
scriptId: env.CONFIG.script_id,
|
||||
coloId: env.COLO_METADATA.coloId,
|
||||
metalId: env.COLO_METADATA.metalId,
|
||||
coloTier: env.COLO_METADATA.coloTier,
|
||||
coloRegion: env.COLO_METADATA.coloRegion,
|
||||
hostname: url.hostname,
|
||||
version: env.VERSION_METADATA.tag,
|
||||
});
|
||||
}
|
||||
inner = loopbackCtx.exports.RouterInnerEntrypoint({ props: {} });
|
||||
} catch (err) {
|
||||
const sentry = setupSentry(
|
||||
request,
|
||||
ctx,
|
||||
env.SENTRY_DSN,
|
||||
env.SENTRY_ACCESS_CLIENT_ID,
|
||||
env.SENTRY_ACCESS_CLIENT_SECRET,
|
||||
env.COLO_METADATA,
|
||||
env.VERSION_METADATA,
|
||||
env.CONFIG?.account_id,
|
||||
env.CONFIG?.script_id
|
||||
);
|
||||
sentry?.captureException(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
analytics.setData({ error: err.message });
|
||||
}
|
||||
analytics.write();
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return inner.fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
export class RouterInnerEntrypoint extends WorkerEntrypoint<Env> {
|
||||
override async fetch(request: Request): Promise<Response> {
|
||||
let sentry: ReturnType<typeof setupSentry> | undefined;
|
||||
let userWorkerInvocation = false;
|
||||
const analytics = new Analytics(this.env.ANALYTICS);
|
||||
const performance = new PerformanceTimer(this.env.UNSAFE_PERFORMANCE);
|
||||
const startTimeMs = performance.now();
|
||||
|
||||
try {
|
||||
if (!this.env.JAEGER) {
|
||||
this.env.JAEGER = mockJaegerBinding();
|
||||
}
|
||||
|
||||
sentry = setupSentry(
|
||||
request,
|
||||
this.ctx,
|
||||
this.env.SENTRY_DSN,
|
||||
this.env.SENTRY_ACCESS_CLIENT_ID,
|
||||
this.env.SENTRY_ACCESS_CLIENT_SECRET,
|
||||
this.env.COLO_METADATA,
|
||||
this.env.VERSION_METADATA,
|
||||
this.env.CONFIG?.account_id,
|
||||
this.env.CONFIG?.script_id
|
||||
);
|
||||
|
||||
const hasStaticRouting = this.env.CONFIG.static_routing !== undefined;
|
||||
const config = applyRouterConfigDefaults(this.env.CONFIG);
|
||||
const eyeballConfig = applyEyeballConfigDefaults(this.env.EYEBALL_CONFIG);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (
|
||||
this.env.COLO_METADATA &&
|
||||
this.env.VERSION_METADATA &&
|
||||
this.env.CONFIG
|
||||
) {
|
||||
analytics.setData({
|
||||
accountId: this.env.CONFIG.account_id,
|
||||
scriptId: this.env.CONFIG.script_id,
|
||||
|
||||
coloId: this.env.COLO_METADATA.coloId,
|
||||
metalId: this.env.COLO_METADATA.metalId,
|
||||
coloTier: this.env.COLO_METADATA.coloTier,
|
||||
|
||||
coloRegion: this.env.COLO_METADATA.coloRegion,
|
||||
hostname: url.hostname,
|
||||
version: this.env.VERSION_METADATA.tag,
|
||||
userWorkerAhead: config.invoke_user_worker_ahead_of_assets,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle /cdn-cgi\... backslash bypass attempts
|
||||
// - in production if pathname starts with `/cdn-cgi/` then it bypassed the external
|
||||
// routing and so must have actually started with `/cdn-cgi\`.
|
||||
// - in local dev it is possible for pathname to start with `/cdn-cgi/`
|
||||
// even if it doesn't start with `/cdn-cgi\` so we also check the raw URL for that.
|
||||
if (
|
||||
url.pathname.startsWith("/cdn-cgi/") &&
|
||||
request.url.includes("/cdn-cgi\\")
|
||||
) {
|
||||
analytics.setData({ cdnCgiBackslashBypassUrl: request.url });
|
||||
return new TemporaryRedirectResponse(url.href);
|
||||
}
|
||||
|
||||
const routeToUserWorker = async ({
|
||||
asset,
|
||||
}: {
|
||||
asset: "static_routing" | "none";
|
||||
}) => {
|
||||
if (!config.has_user_worker) {
|
||||
throw new Error(
|
||||
"Fetch for user worker without having a user worker binding"
|
||||
);
|
||||
}
|
||||
if (eyeballConfig.limitedAssetsOnly) {
|
||||
analytics.setData({ userWorkerFreeTierLimiting: true });
|
||||
return new Response(renderLimitedResponse(request), {
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER });
|
||||
userWorkerInvocation = true;
|
||||
return this.env.JAEGER.enterSpan("dispatch_worker", async (span) => {
|
||||
span.setTags({
|
||||
hasUserWorker: true,
|
||||
asset: asset,
|
||||
dispatchType: DISPATCH_TYPE.WORKER,
|
||||
});
|
||||
|
||||
let shouldCheckContentType = false;
|
||||
if (url.pathname.endsWith("/_next/image")) {
|
||||
// is a next image
|
||||
const queryURLParam = url.searchParams.get("url");
|
||||
if (queryURLParam && !queryURLParam.startsWith("/")) {
|
||||
// that's a remote resource
|
||||
if (
|
||||
request.method !== "GET" ||
|
||||
request.headers.get("sec-fetch-dest") !== "image"
|
||||
) {
|
||||
// that was not loaded via a browser's <img> tag
|
||||
shouldCheckContentType = true;
|
||||
analytics.setData({ abuseMitigationURLHost: queryURLParam });
|
||||
}
|
||||
// otherwise, we're good
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/_image") {
|
||||
const hrefParam = url.searchParams.get("href");
|
||||
if (
|
||||
hrefParam &&
|
||||
hrefParam.length > 2 &&
|
||||
hrefParam.startsWith("//")
|
||||
) {
|
||||
try {
|
||||
const hrefUrl = new URL("https:" + hrefParam);
|
||||
const isImageFetchDest =
|
||||
request.headers.get("sec-fetch-dest") == "image";
|
||||
|
||||
if (hrefUrl.hostname !== url.hostname && !isImageFetchDest) {
|
||||
analytics.setData({ xssDetectionImageHref: hrefParam });
|
||||
return new Response("Blocked", { status: 403 });
|
||||
}
|
||||
} catch {
|
||||
console.log(`Invalid href parameter in /_image: ${hrefParam}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analytics.setData({
|
||||
timeToDispatch: performance.now() - startTimeMs,
|
||||
});
|
||||
|
||||
if (shouldCheckContentType) {
|
||||
const response = await this.env.USER_WORKER.fetch(request);
|
||||
|
||||
if (response.status !== 304 && shouldBlockContentType(response)) {
|
||||
analytics.setData({ abuseMitigationBlocked: true });
|
||||
return new Response("Blocked", { status: 403 });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
return this.env.USER_WORKER.fetch(request);
|
||||
});
|
||||
};
|
||||
|
||||
const routeToAssets = async ({
|
||||
asset,
|
||||
}: {
|
||||
asset: "static_routing" | "found" | "none";
|
||||
}) => {
|
||||
analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS });
|
||||
return await this.env.JAEGER.enterSpan(
|
||||
"dispatch_assets",
|
||||
async (span) => {
|
||||
span.setTags({
|
||||
hasUserWorker: config.has_user_worker,
|
||||
asset: asset,
|
||||
dispatchType: DISPATCH_TYPE.ASSETS,
|
||||
});
|
||||
|
||||
analytics.setData({
|
||||
timeToDispatch: performance.now() - startTimeMs,
|
||||
});
|
||||
return this.env.ASSET_WORKER.fetch(request);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (config.static_routing) {
|
||||
// evaluate "exclude" rules
|
||||
const excludeRulesMatcher = generateStaticRoutingRuleMatcher(
|
||||
config.static_routing.asset_worker ?? []
|
||||
);
|
||||
if (
|
||||
excludeRulesMatcher({
|
||||
request,
|
||||
})
|
||||
) {
|
||||
// direct to asset worker
|
||||
analytics.setData({
|
||||
staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED,
|
||||
});
|
||||
return await routeToAssets({ asset: "static_routing" });
|
||||
}
|
||||
// evaluate "include" rules
|
||||
const includeRulesMatcher = generateStaticRoutingRuleMatcher(
|
||||
config.static_routing.user_worker ?? []
|
||||
);
|
||||
if (
|
||||
includeRulesMatcher({
|
||||
request,
|
||||
})
|
||||
) {
|
||||
if (!config.has_user_worker) {
|
||||
throw new Error(
|
||||
"Fetch for user worker without having a user worker binding"
|
||||
);
|
||||
}
|
||||
// direct to user worker
|
||||
analytics.setData({
|
||||
staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED,
|
||||
});
|
||||
return await routeToUserWorker({ asset: "static_routing" });
|
||||
}
|
||||
|
||||
analytics.setData({
|
||||
staticRoutingDecision: hasStaticRouting
|
||||
? STATIC_ROUTING_DECISION.NOT_ROUTED
|
||||
: STATIC_ROUTING_DECISION.NOT_PROVIDED,
|
||||
});
|
||||
}
|
||||
|
||||
// User's configuration indicates they want user-Worker to run ahead of any
|
||||
// assets. Do not provide any fallback logic.
|
||||
if (config.invoke_user_worker_ahead_of_assets) {
|
||||
return await routeToUserWorker({ asset: "static_routing" });
|
||||
}
|
||||
|
||||
// If we have a user-Worker, but no assets, dispatch to Worker script
|
||||
// Do not pass the original request as it would consume the body
|
||||
const assetsExist = await this.env.ASSET_WORKER.unstable_canFetch(
|
||||
new Request(request.url, {
|
||||
headers: request.headers,
|
||||
method: request.method,
|
||||
})
|
||||
);
|
||||
if (config.has_user_worker && !assetsExist) {
|
||||
return await routeToUserWorker({ asset: "none" });
|
||||
}
|
||||
|
||||
// Otherwise, we either don't have a user worker, OR we have matching assets and should fetch from the assets binding
|
||||
return await routeToAssets({ asset: assetsExist ? "found" : "none" });
|
||||
} catch (err) {
|
||||
if (userWorkerInvocation) {
|
||||
// Don't send user Worker errors to sentry; we have no way to distinguish between
|
||||
// CF errors and errors from the user's code.
|
||||
throw err;
|
||||
} else if (err instanceof Error) {
|
||||
analytics.setData({ error: err.message });
|
||||
}
|
||||
|
||||
// Log to Sentry if we can
|
||||
if (sentry) {
|
||||
sentry.captureException(err);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
analytics.setData({ requestTime: performance.now() - startTimeMs });
|
||||
analytics.write();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Content Type is allowed for the the `_next/image` endpoint.
|
||||
*
|
||||
* - Content Type with multiple values should be blocked
|
||||
* - Only Image and Plain Text types are not blocked
|
||||
*
|
||||
* @param contentType The value of the Content Type header (`null` if no set)
|
||||
* @returns Whether the Content Type should be blocked
|
||||
*/
|
||||
function shouldBlockContentType(response: Response): boolean {
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (contentType === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block responses with multiple Content Types.
|
||||
// https://httpwg.org/specs/rfc9110.html#field.content-type
|
||||
if (contentType.includes(",")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow only
|
||||
// - images (`image/...`)
|
||||
// - plain text (`text/plain`, `text/plain;charset=UTF-8`), used by Next errors
|
||||
return !(
|
||||
contentType.startsWith("image/") ||
|
||||
contentType.split(";")[0] === "text/plain"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers/types"
|
||||
]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["@cloudflare/workers-types/experimental"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: {
|
||||
configPath: "./router-worker/wrangler.jsonc",
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
reporters: ["default"],
|
||||
testTimeout: 50_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type * as RouterWorkerMainModule from "./src/worker";
|
||||
|
||||
// Populates Cloudflare.Exports (the type of ctx.exports) with loopback
|
||||
// bindings derived from the main module's exports.
|
||||
declare namespace Cloudflare {
|
||||
interface GlobalProps {
|
||||
mainModule: typeof RouterWorkerMainModule;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Configuration file for the Router Worker
|
||||
|
||||
Please note that wrangler has a dependency on this file, and will
|
||||
attempt to read it as part of setting up a new Miniflare instance
|
||||
in development mode. We should ensure that any configuration changes
|
||||
to this file are persisted in wrangler as well, when necessary.
|
||||
(see packages/wrangler/src/dev/miniflare.ts -> buildMiniflareOptions())
|
||||
*/
|
||||
{
|
||||
"name": "router-worker",
|
||||
"account_id": "0f1b8aa119a907021f659042f95ea9ba",
|
||||
"workers_dev": false,
|
||||
"main": "src/worker.ts",
|
||||
"compatibility_date": "2024-07-31",
|
||||
// nodejs_compat required when using @cloudflare/vitest-pool-workers
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"no_nodejs_compat_v2",
|
||||
"enable_ctx_exports",
|
||||
],
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
"unsafe": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "routerConfig",
|
||||
},
|
||||
{
|
||||
"name": "EYEBALL_CONFIG",
|
||||
"type": "param",
|
||||
"param": "eyeballRouterConfig",
|
||||
"optional": true,
|
||||
},
|
||||
{
|
||||
"name": "ASSET_WORKER",
|
||||
"type": "internal_assets",
|
||||
"fetcherApi": "fetcher",
|
||||
},
|
||||
{
|
||||
"name": "USER_WORKER",
|
||||
"type": "origin",
|
||||
},
|
||||
{
|
||||
"name": "workers-router-worker",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_router_worker",
|
||||
"networks": ["cf", "jdc"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"env": {
|
||||
"staging": {
|
||||
"name": "router-worker-staging",
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
"unsafe": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "routerConfig",
|
||||
},
|
||||
{
|
||||
"name": "ASSET_WORKER",
|
||||
"type": "internal_assets",
|
||||
"fetcherApi": "fetcher",
|
||||
},
|
||||
{
|
||||
"name": "USER_WORKER",
|
||||
"type": "origin",
|
||||
},
|
||||
{
|
||||
"name": "workers-router-worker-staging",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_router_worker",
|
||||
"networks": ["cf", "jdc"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fed-prod": {
|
||||
"name": "router-worker-fed-prod",
|
||||
"account_id": "fca6c231dc1d1780777997c5bce5a5e3", // workers assets
|
||||
"version_metadata": {
|
||||
"binding": "VERSION_METADATA",
|
||||
},
|
||||
"compliance_region": "fedramp_high",
|
||||
"unsafe": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CONFIG",
|
||||
"type": "param",
|
||||
"param": "routerConfig",
|
||||
},
|
||||
{
|
||||
"name": "ASSET_WORKER",
|
||||
"type": "internal_assets",
|
||||
"fetcherApi": "fetcher",
|
||||
},
|
||||
{
|
||||
"name": "USER_WORKER",
|
||||
"type": "origin",
|
||||
},
|
||||
{
|
||||
"name": "workers-router-worker",
|
||||
"type": "internal_capability_grants",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"build_options": {
|
||||
"stable_id": "cloudflare/cf_router_worker",
|
||||
"networks": ["cf"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* This script is temporary and will be deleted in a next iteration
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
const dstDirectory = join(pkgRoot, "dist");
|
||||
const configFileSrc = join(pkgRoot, "asset-worker", "wrangler.jsonc");
|
||||
const configFileDst = join(dstDirectory, "asset-worker.jsonc");
|
||||
|
||||
if (!existsSync(dstDirectory)) {
|
||||
mkdirSync(dstDirectory);
|
||||
}
|
||||
|
||||
copyFileSync(configFileSrc, configFileDst);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Generates a version tag which can be used with version upload and associated with sentry releases
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
try {
|
||||
const hash = execSync("git rev-parse --short=10 HEAD").toString().trim();
|
||||
console.log(hash);
|
||||
} catch {
|
||||
console.log("UNKNOWN");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Creates a sentry release with associated sourcemaps
|
||||
import SentryCli from "@sentry/cli";
|
||||
|
||||
const requireEnvVar = (varName: string): string => {
|
||||
const varValue = process.env[varName];
|
||||
if (varValue === undefined || varValue === "") {
|
||||
throw new Error(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
return varValue;
|
||||
};
|
||||
|
||||
const requireVar = (varName: string): string => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (args[i].startsWith("--") && args[i].substring(2) === varName) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing required variable: ${varName}`);
|
||||
};
|
||||
|
||||
// Vars
|
||||
const targetWorker = requireVar("worker");
|
||||
const sentryRelease = requireVar("tag");
|
||||
|
||||
// EnvVars
|
||||
const sentryAuthToken = requireEnvVar("WORKERS_SHARED_SENTRY_AUTH_TOKEN");
|
||||
const sentryAccessClientID = requireEnvVar("WORKERS_SHARED_SENTRY_ACCESS_ID");
|
||||
const sentryAccessClientSecret = requireEnvVar(
|
||||
"WORKERS_SHARED_SENTRY_ACCESS_SECRET"
|
||||
);
|
||||
|
||||
// Add a custom header to get through cf access
|
||||
const accessHeader = `cf-access-client-id: ${sentryAccessClientID}
|
||||
cf-access-client-secret: ${sentryAccessClientSecret}`;
|
||||
|
||||
async function generateRelease(worker: string, release: string) {
|
||||
const dir = `./${worker}/dist`;
|
||||
console.log(`Dir path: ${dir}`);
|
||||
|
||||
const sentryCli = new SentryCli(null, {
|
||||
org: "cloudflare",
|
||||
project: worker,
|
||||
url: "https://sentry10.cfdata.org/",
|
||||
authToken: sentryAuthToken,
|
||||
customHeader: accessHeader,
|
||||
});
|
||||
|
||||
console.log(`Creating release: ${release}`);
|
||||
await sentryCli.releases.new(release);
|
||||
|
||||
console.log("Finalizing release");
|
||||
await sentryCli.releases.finalize(release);
|
||||
|
||||
console.log("Uploading sourcemaps");
|
||||
await sentryCli.releases.uploadSourceMaps(release, {
|
||||
include: [dir],
|
||||
urlPrefix: "/",
|
||||
});
|
||||
}
|
||||
|
||||
generateRelease(targetWorker, sentryRelease)
|
||||
.then(() =>
|
||||
console.log(`Successfully uploaded sourcemaps for ${targetWorker}`)
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"types": ["@cloudflare/workers-types/experimental", "@types/node"]
|
||||
},
|
||||
"include": ["**/*.ts", "vitest.config.mts"],
|
||||
"exclude": ["node_modules", "dist", "**/tests", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const REDIRECTS_VERSION = 1;
|
||||
export const HEADERS_VERSION = 2;
|
||||
|
||||
export const PERMITTED_STATUS_CODES = new Set([200, 301, 302, 303, 307, 308]);
|
||||
export const HEADER_SEPARATOR = ":";
|
||||
export const MAX_LINE_LENGTH = 2000;
|
||||
export const MAX_HEADER_RULES = 100;
|
||||
export const MAX_DYNAMIC_REDIRECT_RULES = 100;
|
||||
export const MAX_STATIC_REDIRECT_RULES = 2000;
|
||||
export const UNSET_OPERATOR = "! ";
|
||||
|
||||
export const SPLAT_REGEX = /\*/g;
|
||||
export const PLACEHOLDER_REGEX = /:[A-Za-z]\w*/g;
|
||||
|
||||
/** Max number of rules in `run_worker_first` */
|
||||
export const MAX_ROUTES_RULES = 100;
|
||||
/** Max char length of each rule in `run_worker_first` */
|
||||
export const MAX_ROUTES_RULE_LENGTH = 100;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { relative } from "node:path";
|
||||
import {
|
||||
HEADERS_VERSION,
|
||||
PLACEHOLDER_REGEX,
|
||||
REDIRECTS_VERSION,
|
||||
SPLAT_REGEX,
|
||||
} from "./constants";
|
||||
import type {
|
||||
AssetConfig,
|
||||
MetadataHeaders,
|
||||
MetadataRedirects,
|
||||
MetadataStaticRedirects,
|
||||
} from "../types";
|
||||
import type { Logger, ParsedHeaders, ParsedRedirects } from "./types";
|
||||
|
||||
export function constructRedirects({
|
||||
redirects,
|
||||
redirectsFile,
|
||||
logger,
|
||||
}: {
|
||||
redirects?: ParsedRedirects;
|
||||
redirectsFile?: string;
|
||||
logger: Logger;
|
||||
}): Pick<AssetConfig, "redirects"> {
|
||||
if (!redirects) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const num_valid = redirects.rules.length;
|
||||
const num_invalid = redirects.invalid.length;
|
||||
|
||||
// exhaustive check, since we could not have parsed `redirects` out of
|
||||
// a non-existing redirects file
|
||||
const redirectsRelativePath = redirectsFile
|
||||
? relative(process.cwd(), redirectsFile)
|
||||
: "";
|
||||
|
||||
logger.log(
|
||||
`✨ Parsed ${num_valid} valid redirect rule${num_valid === 1 ? "" : "s"}.`
|
||||
);
|
||||
|
||||
if (num_invalid > 0) {
|
||||
let invalidRedirectRulesList = ``;
|
||||
|
||||
for (const { line, lineNumber, message } of redirects.invalid) {
|
||||
invalidRedirectRulesList += `▶︎ ${message}\n`;
|
||||
|
||||
if (line) {
|
||||
invalidRedirectRulesList += ` at ${redirectsRelativePath}${lineNumber ? `:${lineNumber}` : ""} | ${line}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Found ${num_invalid} invalid redirect rule${num_invalid === 1 ? "" : "s"}:\n` +
|
||||
`${invalidRedirectRulesList}`
|
||||
);
|
||||
}
|
||||
|
||||
/* Better to return no Redirects object at all than one with empty rules */
|
||||
if (num_valid === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const staticRedirects: MetadataStaticRedirects = {};
|
||||
const dynamicRedirects: MetadataRedirects = {};
|
||||
let canCreateStaticRule = true;
|
||||
for (const rule of redirects.rules) {
|
||||
if (!rule.from.match(SPLAT_REGEX) && !rule.from.match(PLACEHOLDER_REGEX)) {
|
||||
if (canCreateStaticRule) {
|
||||
staticRedirects[rule.from] = {
|
||||
status: rule.status,
|
||||
to: rule.to,
|
||||
lineNumber: rule.lineNumber,
|
||||
};
|
||||
continue;
|
||||
} else {
|
||||
logger.info(
|
||||
`The redirect rule ${rule.from} → ${rule.status} ${rule.to} could be made more performant by bringing it above any lines with splats or placeholders.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dynamicRedirects[rule.from] = { status: rule.status, to: rule.to };
|
||||
canCreateStaticRule = false;
|
||||
}
|
||||
|
||||
return {
|
||||
redirects: {
|
||||
version: REDIRECTS_VERSION,
|
||||
staticRules: staticRedirects,
|
||||
rules: dynamicRedirects,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function constructHeaders({
|
||||
headers,
|
||||
headersFile,
|
||||
logger,
|
||||
}: {
|
||||
headers?: ParsedHeaders;
|
||||
headersFile?: string;
|
||||
logger: Logger;
|
||||
}): Pick<AssetConfig, "headers"> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const num_valid = headers.rules.length;
|
||||
const num_invalid = headers.invalid.length;
|
||||
|
||||
// exhaustive check, since we could not have parsed `headers` out of
|
||||
// a non-existing headers file
|
||||
const headersRelativePath = headersFile
|
||||
? relative(process.cwd(), headersFile)
|
||||
: "";
|
||||
|
||||
logger.log(
|
||||
`✨ Parsed ${num_valid} valid header rule${num_valid === 1 ? "" : "s"}.`
|
||||
);
|
||||
|
||||
if (num_invalid > 0) {
|
||||
let invalidHeaderRulesList = ``;
|
||||
|
||||
for (const { line, lineNumber, message } of headers.invalid) {
|
||||
invalidHeaderRulesList += `▶︎ ${message}\n`;
|
||||
|
||||
if (line) {
|
||||
invalidHeaderRulesList += ` at ${headersRelativePath}${lineNumber ? `:${lineNumber}` : ""} | ${line}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Found ${num_invalid} invalid header rule${num_invalid === 1 ? "" : "s"}:\n` +
|
||||
`${invalidHeaderRulesList}`
|
||||
);
|
||||
}
|
||||
|
||||
/* Better to return no Headers object at all than one with empty rules */
|
||||
if (num_valid === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rules: MetadataHeaders = {};
|
||||
for (const rule of headers.rules) {
|
||||
const configuredRule: MetadataHeaders[string] = {};
|
||||
|
||||
if (Object.keys(rule.headers).length) {
|
||||
configuredRule.set = rule.headers;
|
||||
}
|
||||
if (rule.unsetHeaders.length) {
|
||||
configuredRule.unset = rule.unsetHeaders;
|
||||
}
|
||||
|
||||
rules[rule.path] = configuredRule;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
version: HEADERS_VERSION,
|
||||
rules,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
HEADER_SEPARATOR,
|
||||
MAX_HEADER_RULES,
|
||||
MAX_LINE_LENGTH,
|
||||
SPLAT_REGEX,
|
||||
UNSET_OPERATOR,
|
||||
} from "./constants";
|
||||
import { validateUrl } from "./validateURL";
|
||||
import type { HeadersRule, InvalidHeadersRule, ParsedHeaders } from "./types";
|
||||
|
||||
// Not strictly necessary to check for all protocols-like beginnings, since _technically_ that could be a legit header (e.g. name=http, value=://I'm a value).
|
||||
// But we're checking here since some people might be caught out and it'll help 99.9% of people who get it wrong.
|
||||
// We do the proper validation in `validateUrl` anyway :)
|
||||
const LINE_IS_PROBABLY_A_PATH = new RegExp(/^([^\s]+:\/\/|^\/)/);
|
||||
|
||||
export function parseHeaders(
|
||||
input: string,
|
||||
{
|
||||
maxRules = MAX_HEADER_RULES,
|
||||
maxLineLength = MAX_LINE_LENGTH,
|
||||
}: { maxRules?: number; maxLineLength?: number } = {}
|
||||
): ParsedHeaders {
|
||||
const lines = input.split("\n");
|
||||
const rules: HeadersRule[] = [];
|
||||
const invalid: InvalidHeadersRule[] = [];
|
||||
|
||||
let rule: (HeadersRule & { line: string }) | undefined = undefined;
|
||||
// When a path line is rejected (invalid URL, multiple wildcards, etc.),
|
||||
// we silently skip subsequent header lines until the next path line
|
||||
// rather than emitting confusing "Path should come before header" errors.
|
||||
let skipUntilNextPath = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] || "").trim();
|
||||
if (line.length === 0 || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length > maxLineLength) {
|
||||
invalid.push({
|
||||
message: `Ignoring line ${
|
||||
i + 1
|
||||
} as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LINE_IS_PROBABLY_A_PATH.test(line)) {
|
||||
skipUntilNextPath = false;
|
||||
|
||||
if (rules.length >= maxRules) {
|
||||
invalid.push({
|
||||
message: `Maximum number of rules supported is ${maxRules}. Skipping remaining ${
|
||||
lines.length - i
|
||||
} lines of file.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
if (isValidRule(rule)) {
|
||||
rules.push({
|
||||
path: rule.path,
|
||||
headers: rule.headers,
|
||||
unsetHeaders: rule.unsetHeaders,
|
||||
});
|
||||
} else {
|
||||
invalid.push({
|
||||
line: rule.line,
|
||||
lineNumber: i + 1,
|
||||
message: "No headers specified",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [path, pathError] = validateUrl(line, false, true);
|
||||
if (pathError) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: pathError,
|
||||
});
|
||||
rule = undefined;
|
||||
skipUntilNextPath = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wildcardError = validateNoMultipleWildcards(path as string);
|
||||
if (wildcardError) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: wildcardError,
|
||||
});
|
||||
rule = undefined;
|
||||
skipUntilNextPath = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
rule = {
|
||||
path: path as string,
|
||||
line,
|
||||
headers: {},
|
||||
unsetHeaders: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipUntilNextPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.includes(HEADER_SEPARATOR)) {
|
||||
if (!rule) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "Expected a path beginning with at least one forward-slash",
|
||||
});
|
||||
} else {
|
||||
if (line.trim().startsWith(UNSET_OPERATOR)) {
|
||||
rule.unsetHeaders.push(line.trim().replace(UNSET_OPERATOR, ""));
|
||||
} else {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message:
|
||||
"Expected a colon-separated header pair (e.g. name: value)",
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawName, ...rawValue] = line.split(HEADER_SEPARATOR);
|
||||
const name = (rawName || "").trim().toLowerCase();
|
||||
|
||||
if (name.includes(" ")) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "Header name cannot include spaces",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = rawValue.join(HEADER_SEPARATOR).trim();
|
||||
|
||||
if (name === "") {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "No header name specified",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "No header value specified",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Path should come before header (${name}: ${value})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingValues = rule.headers[name];
|
||||
rule.headers[name] = existingValues ? `${existingValues}, ${value}` : value;
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
if (isValidRule(rule)) {
|
||||
rules.push({
|
||||
path: rule.path,
|
||||
headers: rule.headers,
|
||||
unsetHeaders: rule.unsetHeaders,
|
||||
});
|
||||
} else {
|
||||
invalid.push({ line: rule.line, message: "No headers specified" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
invalid,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidRule(rule: HeadersRule) {
|
||||
return Object.keys(rule.headers).length > 0 || rule.unsetHeaders.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* At runtime, `*` wildcards are converted to `:splat` placeholders. This means
|
||||
* a path with multiple wildcards, or a wildcard combined with an explicit
|
||||
* `:splat` placeholder, would result in duplicate `:splat` parameters which is
|
||||
* unsupported.
|
||||
*/
|
||||
function validateNoMultipleWildcards(path: string): string | undefined {
|
||||
const wildcardCount = (path.match(SPLAT_REGEX) ?? []).length;
|
||||
const hasSplatPlaceholder = /:splat(?!\w)/.test(path);
|
||||
|
||||
if (wildcardCount > 1) {
|
||||
return `Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping ${path}.`;
|
||||
}
|
||||
|
||||
if (wildcardCount > 0 && hasSplatPlaceholder) {
|
||||
return `Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping ${path}.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
MAX_DYNAMIC_REDIRECT_RULES,
|
||||
MAX_LINE_LENGTH,
|
||||
MAX_STATIC_REDIRECT_RULES,
|
||||
PERMITTED_STATUS_CODES,
|
||||
PLACEHOLDER_REGEX,
|
||||
SPLAT_REGEX,
|
||||
} from "./constants";
|
||||
import { urlHasHost, validateUrl } from "./validateURL";
|
||||
import type { AssetConfig } from "../types";
|
||||
import type {
|
||||
InvalidRedirectRule,
|
||||
ParsedRedirects,
|
||||
RedirectLine,
|
||||
RedirectRule,
|
||||
} from "./types";
|
||||
|
||||
export function parseRedirects(
|
||||
input: string,
|
||||
{
|
||||
htmlHandling = undefined,
|
||||
maxStaticRules = MAX_STATIC_REDIRECT_RULES,
|
||||
maxDynamicRules = MAX_DYNAMIC_REDIRECT_RULES,
|
||||
maxLineLength = MAX_LINE_LENGTH,
|
||||
}: {
|
||||
htmlHandling?: AssetConfig["html_handling"];
|
||||
maxStaticRules?: number;
|
||||
maxDynamicRules?: number;
|
||||
maxLineLength?: number;
|
||||
} = {}
|
||||
): ParsedRedirects {
|
||||
const lines = input.split("\n");
|
||||
const rules: RedirectRule[] = [];
|
||||
const seen_paths = new Set<string>();
|
||||
const invalid: InvalidRedirectRule[] = [];
|
||||
|
||||
let staticRules = 0;
|
||||
let dynamicRules = 0;
|
||||
let canCreateStaticRule = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] || "").trim();
|
||||
if (line.length === 0 || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length > maxLineLength) {
|
||||
invalid.push({
|
||||
message: `Ignoring line ${
|
||||
i + 1
|
||||
} as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle inline comments: strip off from the first `#` token that starts a comment, but not URL fragments.
|
||||
// This allows `/a /b#fragment` but strips `/a /b # comment`
|
||||
const tokens = line.replace(/\s+#.*$/, "").split(/\s+/);
|
||||
|
||||
if (tokens.length < 2 || tokens.length > 3) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Expected exactly 2 or 3 whitespace-separated tokens. Got ${tokens.length}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const [str_from, str_to, str_status = "302"] = tokens as RedirectLine;
|
||||
|
||||
const fromResult = validateUrl(str_from, true, true, false, false);
|
||||
if (fromResult[0] === undefined) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: fromResult[1],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const from = fromResult[0];
|
||||
|
||||
if (
|
||||
canCreateStaticRule &&
|
||||
!from.match(SPLAT_REGEX) &&
|
||||
!from.match(PLACEHOLDER_REGEX)
|
||||
) {
|
||||
staticRules += 1;
|
||||
|
||||
if (staticRules > maxStaticRules) {
|
||||
invalid.push({
|
||||
message: `Maximum number of static rules supported is ${maxStaticRules}. Skipping line.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
dynamicRules += 1;
|
||||
canCreateStaticRule = false;
|
||||
|
||||
if (dynamicRules > maxDynamicRules) {
|
||||
invalid.push({
|
||||
message: `Maximum number of dynamic rules supported is ${maxDynamicRules}. Skipping remaining ${
|
||||
lines.length - i
|
||||
} lines of file.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const toResult = validateUrl(str_to, false, false, true, true);
|
||||
if (toResult[0] === undefined) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: toResult[1],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const to = toResult[0];
|
||||
|
||||
const status = Number(str_status);
|
||||
if (isNaN(status) || !PERMITTED_STATUS_CODES.has(status)) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Valid status codes are 200, 301, 302 (default), 303, 307, or 308. Got ${str_status}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Here we reject two patterns:
|
||||
// 1. `/* /index.html` Is always rejected.
|
||||
// 2. `/ /index` Is rejected when HTML handling is enabled.
|
||||
// Allowing the redirect in other cases will cause TOO_MANY_REDIRECTS errors as the asset Worker will
|
||||
// redirect it back to `/` by removing the `/index.html`.
|
||||
// We only want to run this on relative URLs.
|
||||
const hasRelativePath = !urlHasHost(to);
|
||||
const hasWildcardToIndex =
|
||||
from.endsWith("/*") && /\/index(.html)?$/.test(to);
|
||||
const hasRootToIndex = from.endsWith("/") && /\/index(.html)?$/.test(to);
|
||||
const hasHTMLHandling = htmlHandling !== "none"; // HTML handling is enabled by default.
|
||||
if (
|
||||
hasRelativePath &&
|
||||
(hasWildcardToIndex || (hasRootToIndex && hasHTMLHandling))
|
||||
) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message:
|
||||
"Infinite loop detected in this rule and has been ignored. This will cause a redirect to strip `.html` or `/index` and end up triggering this rule again. Please fix or remove this rule to silence this warning.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen_paths.has(from)) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Ignoring duplicate rule for path ${from}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seen_paths.add(from);
|
||||
|
||||
if (status === 200) {
|
||||
if (urlHasHost(to)) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Proxy (200) redirects can only point to relative paths. Got ${to}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rules.push({ from, to, status, lineNumber: i + 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
invalid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MAX_ROUTES_RULE_LENGTH, MAX_ROUTES_RULES } from "./constants";
|
||||
import type { StaticRouting } from "../types";
|
||||
|
||||
// copy of what EWC does. Wrangler uploads the rules in one array (so the API is consistent with Wrangler config),
|
||||
// but router Worker expects the rules to be split into two arrays, which we do here.
|
||||
// This logic is translated from assets/staticrouting.go.
|
||||
|
||||
export function parseStaticRouting(input: string[]): StaticRouting {
|
||||
if (input.length === 0) {
|
||||
throw new Error(
|
||||
"No `run_worker_first` rules were provided; must provide at least 1 rule."
|
||||
);
|
||||
}
|
||||
if (input.length > MAX_ROUTES_RULES) {
|
||||
throw new Error(
|
||||
`Too many \`run_worker_first\` rules were provided; ${input.length} rules provided exceeds max of ${MAX_ROUTES_RULES}.`
|
||||
);
|
||||
}
|
||||
|
||||
const rawAssetWorkerRules = [];
|
||||
const assetWorkerRules = [];
|
||||
const userWorkerRules = [];
|
||||
const invalidRules = [];
|
||||
|
||||
for (const rule of input) {
|
||||
if (rule.startsWith("!/")) {
|
||||
assetWorkerRules.push(rule.slice(1)); // Remove leading !
|
||||
rawAssetWorkerRules.push(rule);
|
||||
} else if (rule.startsWith("/")) {
|
||||
userWorkerRules.push(rule);
|
||||
} else if (rule.startsWith("!")) {
|
||||
invalidRules.push(`'${rule}': negative rules must start with '!/'`);
|
||||
} else {
|
||||
invalidRules.push(`'${rule}': rules must start with '/' or '!/'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (assetWorkerRules.length > 0 && userWorkerRules.length === 0) {
|
||||
throw new Error(
|
||||
"Only negative `run_worker_first` rules were provided; must provide at least 1 non-negative rule"
|
||||
);
|
||||
}
|
||||
|
||||
const invalidAssetWorkerRules =
|
||||
validateStaticRoutingRules(rawAssetWorkerRules);
|
||||
const invalidUserWorkerRules = validateStaticRoutingRules(userWorkerRules);
|
||||
const errorMessage = formatInvalidRoutes([
|
||||
...invalidRules,
|
||||
...invalidUserWorkerRules,
|
||||
...invalidAssetWorkerRules,
|
||||
]);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return { asset_worker: assetWorkerRules, user_worker: userWorkerRules };
|
||||
}
|
||||
|
||||
function validateStaticRoutingRules(rules: string[]): string[] {
|
||||
const invalid: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
if (rule.length > MAX_ROUTES_RULE_LENGTH) {
|
||||
invalid.push(
|
||||
`'${rule}': all rules must be less than ${MAX_ROUTES_RULE_LENGTH} characters in length`
|
||||
);
|
||||
}
|
||||
if (seen.has(rule)) {
|
||||
invalid.push(`'${rule}': rule is a duplicate; rules must be unique`);
|
||||
}
|
||||
if (rule.endsWith("*")) {
|
||||
// Check for redundant rules due to a glob
|
||||
for (const otherRule of rules) {
|
||||
if (otherRule !== rule && otherRule.startsWith(rule.slice(0, -1))) {
|
||||
invalid.push(`'${otherRule}': rule '${rule}' makes it redundant`);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.add(rule);
|
||||
}
|
||||
return invalid;
|
||||
}
|
||||
|
||||
const formatInvalidRoutes = (invalidRules: string[]) => {
|
||||
if (invalidRules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `Invalid routes in \`run_worker_first\`:\n` + invalidRules.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
export type RedirectLine = [from: string, to: string, status?: number];
|
||||
export type RedirectRule = {
|
||||
from: string;
|
||||
to: string;
|
||||
status: number;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export type Headers = Record<string, string>;
|
||||
export type HeadersRule = {
|
||||
path: string;
|
||||
headers: Headers;
|
||||
unsetHeaders: string[];
|
||||
};
|
||||
|
||||
export type InvalidRedirectRule = {
|
||||
line?: string;
|
||||
lineNumber?: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type InvalidHeadersRule = {
|
||||
line?: string;
|
||||
lineNumber?: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ParsedRedirects = {
|
||||
invalid: InvalidRedirectRule[];
|
||||
rules: RedirectRule[];
|
||||
};
|
||||
|
||||
export type ParsedHeaders = {
|
||||
invalid: InvalidHeadersRule[];
|
||||
rules: HeadersRule[];
|
||||
};
|
||||
|
||||
export interface Logger {
|
||||
debug: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const extractPathname = (
|
||||
path = "/",
|
||||
includeSearch: boolean,
|
||||
includeHash: boolean
|
||||
): string => {
|
||||
if (!path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
const url = new URL(`//${path}`, "relative://");
|
||||
return `${url.pathname}${includeSearch ? url.search : ""}${
|
||||
includeHash ? url.hash : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
const URL_REGEX = /^https:\/\/+(?<host>[^/]+)\/?(?<path>.*)/;
|
||||
const HOST_WITH_PORT_REGEX = /.*:\d+$/;
|
||||
const PATH_REGEX = /^\//;
|
||||
|
||||
export const validateUrl = (
|
||||
token: string,
|
||||
onlyRelative = false,
|
||||
disallowPorts = false,
|
||||
includeSearch = false,
|
||||
includeHash = false
|
||||
): [undefined, string] | [string, undefined] => {
|
||||
const host = URL_REGEX.exec(token);
|
||||
if (host && host.groups && host.groups.host) {
|
||||
if (onlyRelative) {
|
||||
return [
|
||||
undefined,
|
||||
`Only relative URLs are allowed. Skipping absolute URL ${token}.`,
|
||||
];
|
||||
}
|
||||
|
||||
if (disallowPorts && host.groups.host.match(HOST_WITH_PORT_REGEX)) {
|
||||
return [
|
||||
undefined,
|
||||
`Specifying ports is not supported. Skipping absolute URL ${token}.`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`https://${host.groups.host}${extractPathname(
|
||||
host.groups.path,
|
||||
includeSearch,
|
||||
includeHash
|
||||
)}`,
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
if (!token.startsWith("/") && onlyRelative) {
|
||||
token = `/${token}`;
|
||||
}
|
||||
|
||||
const path = PATH_REGEX.exec(token);
|
||||
if (path) {
|
||||
try {
|
||||
return [extractPathname(token, includeSearch, includeHash), undefined];
|
||||
} catch {
|
||||
return [undefined, `Error parsing URL segment ${token}. Skipping.`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
undefined,
|
||||
onlyRelative
|
||||
? "URLs should begin with a forward-slash."
|
||||
: 'URLs should either be relative (e.g. begin with a forward-slash), or use HTTPS (e.g. begin with "https://").',
|
||||
];
|
||||
};
|
||||
|
||||
export function urlHasHost(token: string): boolean {
|
||||
const host = URL_REGEX.exec(token);
|
||||
return Boolean(host && host.groups && host.groups.host);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// -- 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; this is a global
|
||||
* ceiling, and may vary by the user's subscription.
|
||||
*/
|
||||
export const MAX_ASSET_COUNT = 100_000;
|
||||
/** Maximum size per asset that can be deployed with a Worker */
|
||||
export const MAX_ASSET_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
export const CF_ASSETS_IGNORE_FILENAME = ".assetsignore";
|
||||
export const REDIRECTS_FILENAME = "_redirects";
|
||||
export const HEADERS_FILENAME = "_headers";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, resolve, sep } from "node:path";
|
||||
import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import {
|
||||
CF_ASSETS_IGNORE_FILENAME,
|
||||
HEADERS_FILENAME,
|
||||
REDIRECTS_FILENAME,
|
||||
} from "./constants";
|
||||
import type { PathOrFileDescriptor } from "node:fs";
|
||||
|
||||
/** normalises sep for windows and prefix with `/` */
|
||||
export const normalizeFilePath = (relativeFilepath: string) => {
|
||||
if (isAbsolute(relativeFilepath)) {
|
||||
throw new Error(`Expected relative path`);
|
||||
}
|
||||
return "/" + relativeFilepath.split(sep).join("/");
|
||||
};
|
||||
|
||||
export const getContentType = (absFilePath: string) => {
|
||||
let contentType = mime.getType(absFilePath);
|
||||
if (
|
||||
contentType &&
|
||||
contentType.startsWith("text/") &&
|
||||
!contentType.includes("charset")
|
||||
) {
|
||||
contentType = `${contentType}; charset=utf-8`;
|
||||
}
|
||||
return contentType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a function that can match relative filepaths against a list of gitignore formatted patterns.
|
||||
*/
|
||||
export function createPatternMatcher(
|
||||
patterns: string[],
|
||||
exclude: boolean
|
||||
): (filePath: string) => boolean {
|
||||
if (patterns.length === 0) {
|
||||
return (_filePath) => !exclude;
|
||||
} else {
|
||||
const ignorer = ignore().add(patterns);
|
||||
return (filePath) => ignorer.test(filePath).ignored;
|
||||
}
|
||||
}
|
||||
|
||||
export function thrownIsDoesNotExistError(
|
||||
thrown: unknown
|
||||
): thrown is Error & { code: "ENOENT" } {
|
||||
return (
|
||||
thrown instanceof Error && "code" in thrown && thrown.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export function maybeGetFile(filePath: PathOrFileDescriptor) {
|
||||
try {
|
||||
return readFileSync(filePath, "utf8");
|
||||
} catch (e: unknown) {
|
||||
if (!thrownIsDoesNotExistError(e)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a function for filtering out ignored assets.
|
||||
*
|
||||
* The generated function takes an asset path, relative to the asset directory,
|
||||
* and returns true if the asset should not be ignored.
|
||||
*/
|
||||
export async function createAssetsIgnoreFunction(dir: string) {
|
||||
const cfAssetIgnorePath = resolve(dir, CF_ASSETS_IGNORE_FILENAME);
|
||||
|
||||
const ignorePatterns = [
|
||||
// Ignore the `.assetsignore` file and other metafiles by default.
|
||||
// The ignore lib expects unix-style paths for its patterns
|
||||
`/${CF_ASSETS_IGNORE_FILENAME}`,
|
||||
`/${REDIRECTS_FILENAME}`,
|
||||
`/${HEADERS_FILENAME}`,
|
||||
];
|
||||
|
||||
let assetsIgnoreFilePresent = false;
|
||||
const assetsIgnore = maybeGetFile(cfAssetIgnorePath);
|
||||
if (assetsIgnore !== undefined) {
|
||||
assetsIgnoreFilePresent = true;
|
||||
ignorePatterns.push(...assetsIgnore.split("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
assetsIgnoreFunction: createPatternMatcher(ignorePatterns, true),
|
||||
assetsIgnoreFilePresent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { UnsafePerformanceTimer } from "./types";
|
||||
|
||||
export class PerformanceTimer {
|
||||
private performanceTimer;
|
||||
|
||||
constructor(performanceTimer?: UnsafePerformanceTimer) {
|
||||
this.performanceTimer = performanceTimer;
|
||||
}
|
||||
|
||||
now() {
|
||||
if (this.performanceTimer) {
|
||||
return this.performanceTimer.timeOrigin + this.performanceTimer.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
export class OkResponse extends Response {
|
||||
static readonly status = 200;
|
||||
|
||||
constructor(body: BodyInit | null, init?: ResponseInit) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: OkResponse.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundResponse extends Response {
|
||||
static readonly status = 404;
|
||||
|
||||
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: NotFoundResponse.status,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A magical response type which is used to signal that a user worker should be invoked if one is present.
|
||||
export class NoIntentResponse extends NotFoundResponse {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class MethodNotAllowedResponse extends Response {
|
||||
static readonly status = 405;
|
||||
|
||||
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: MethodNotAllowedResponse.status,
|
||||
statusText: "Method Not Allowed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerErrorResponse extends Response {
|
||||
static readonly status = 500;
|
||||
|
||||
constructor(_: Error, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: InternalServerErrorResponse.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotModifiedResponse extends Response {
|
||||
static readonly status = 304;
|
||||
|
||||
constructor(...[_body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: NotModifiedResponse.status,
|
||||
statusText: "Not Modified",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MovedPermanentlyResponse extends Response {
|
||||
static readonly status = 301;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: MovedPermanentlyResponse.status,
|
||||
statusText: "Moved Permanently",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FoundResponse extends Response {
|
||||
static readonly status = 302;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: FoundResponse.status,
|
||||
statusText: "Found",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SeeOtherResponse extends Response {
|
||||
static readonly status = 303;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: SeeOtherResponse.status,
|
||||
statusText: "See Other",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TemporaryRedirectResponse extends Response {
|
||||
static readonly status = 307;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: TemporaryRedirectResponse.status,
|
||||
statusText: "Temporary Redirect",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PermanentRedirectResponse extends Response {
|
||||
static readonly status = 308;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: PermanentRedirectResponse.status,
|
||||
statusText: "Permanent Redirect",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { rewriteFramesIntegration, Toucan } from "toucan-js";
|
||||
import type { ColoMetadata } from "./types";
|
||||
|
||||
export function setupSentry(
|
||||
request: Request,
|
||||
context: ExecutionContext | undefined,
|
||||
dsn: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
coloMetadata?: ColoMetadata,
|
||||
versionMetadata?: WorkerVersionMetadata,
|
||||
accountId?: number,
|
||||
scriptId?: number
|
||||
): Toucan | undefined {
|
||||
// Are we running locally without access to Sentry secrets? If so, don't initialise Sentry
|
||||
if (!(dsn && clientId && clientSecret)) {
|
||||
return undefined;
|
||||
}
|
||||
const sentry = new Toucan({
|
||||
dsn,
|
||||
request,
|
||||
context,
|
||||
sampleRate: 1.0,
|
||||
release: versionMetadata?.tag,
|
||||
integrations: [
|
||||
rewriteFramesIntegration({
|
||||
iteratee(frame) {
|
||||
frame.filename = "/index.js";
|
||||
return frame;
|
||||
},
|
||||
}),
|
||||
],
|
||||
requestDataOptions: {
|
||||
allowedHeaders: [
|
||||
"user-agent",
|
||||
"cf-challenge",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cf-ray",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"host",
|
||||
],
|
||||
allowedSearchParams: /(.*)/,
|
||||
},
|
||||
|
||||
transportOptions: {
|
||||
headers: {
|
||||
"CF-Access-Client-ID": clientId,
|
||||
"CF-Access-Client-Secret": clientSecret,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (coloMetadata) {
|
||||
sentry.setTag("colo", coloMetadata.coloId);
|
||||
sentry.setTag("metal", coloMetadata.metalId);
|
||||
}
|
||||
|
||||
if (accountId && scriptId) {
|
||||
sentry.setTag("accountId", accountId);
|
||||
sentry.setTag("scriptId", scriptId);
|
||||
}
|
||||
|
||||
sentry.setUser({ id: accountId?.toString() });
|
||||
|
||||
return sentry;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "vitest";
|
||||
import { createAssetsIgnoreFunction, getContentType } from "../helpers";
|
||||
|
||||
describe("assets", () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "wrangler-tests"));
|
||||
|
||||
describe(".assetsignore", () => {
|
||||
it("should ignore metafiles by default", async ({ expect }) => {
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction(".assetsignore")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("_redirects")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("_headers")).toBeTruthy();
|
||||
|
||||
// don't ignore metafiles in child directories
|
||||
expect(assetsIgnoreFunction(join("child", ".assetsignore"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "_redirects"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "_headers"))).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should allow users to force opt-in metafiles", async ({ expect }) => {
|
||||
await writeFile(
|
||||
join(tmpDir, "./.assetsignore"),
|
||||
"!.assetsignore\n!_redirects\n!_headers"
|
||||
);
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction(".assetsignore")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("_redirects")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("_headers")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should allow users to ignore files", async ({ expect }) => {
|
||||
await writeFile(
|
||||
join(tmpDir, "./.assetsignore"),
|
||||
"logo.png\nchild/**/*.svg\n!child/nope.svg\n/*.js"
|
||||
);
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction("abc")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("logo.png")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "logo.png"))).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("foo.js")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "foo.js"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "yup.svg"))).toBeTruthy();
|
||||
expect(
|
||||
assetsIgnoreFunction(join("child", "a", "b", "c", "yup.svg"))
|
||||
).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "nope.svg"))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContentType", () => {
|
||||
it("should return 'text/javascript", ({ expect }) => {
|
||||
const contentType = getContentType("/_astro/sponsors.CIiPz7eJ.js");
|
||||
expect(contentType).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { test } from "vitest";
|
||||
import { parseHeaders } from "../configuration/parseHeaders";
|
||||
|
||||
test("parseHeaders should reject malformed initial lines", ({ expect }) => {
|
||||
const input = `
|
||||
# A single token before a path
|
||||
c
|
||||
# A header before a path
|
||||
Access-Control-Allow-Origin: *
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: "c",
|
||||
lineNumber: 3,
|
||||
message: "Expected a path beginning with at least one forward-slash",
|
||||
},
|
||||
{
|
||||
line: "Access-Control-Allow-Origin: *",
|
||||
lineNumber: 5,
|
||||
message:
|
||||
"Path should come before header (access-control-allow-origin: *)",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject invalid headers", ({ expect }) => {
|
||||
const input = `
|
||||
# Valid header sails through
|
||||
/a
|
||||
Name: Value
|
||||
#
|
||||
/b
|
||||
I'm invalid!
|
||||
!x-content-type
|
||||
But: I'm okay!
|
||||
! Content-Type: application/json
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { but: "I'm okay!" }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: `I'm invalid!`,
|
||||
lineNumber: 7,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{
|
||||
line: "!x-content-type",
|
||||
lineNumber: 8,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{
|
||||
line: `! Content-Type: application/json`,
|
||||
lineNumber: 10,
|
||||
message: "Header name cannot include spaces",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject lines longer than 2000 chars", ({
|
||||
expect,
|
||||
}) => {
|
||||
const huge_line = `${Array(1001).fill("a").join("")}: ${Array(1001)
|
||||
.fill("b")
|
||||
.join("")}`;
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a
|
||||
Name: Value
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
/b
|
||||
Name: Value
|
||||
${huge_line}
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
message: `Ignoring line 10 as it exceeds the maximum allowed length of 2000.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject any rules after the first 100", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}\nx-index: ${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseHeaders(input)).toEqual({
|
||||
rules: Array(101)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
path: "/a/" + i,
|
||||
headers: { "x-index": `${i}` },
|
||||
unsetHeaders: [],
|
||||
})),
|
||||
invalid: [
|
||||
{
|
||||
message: `Maximum number of rules supported is 100. Skipping remaining 100 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject paths with multiple wildcards", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# Multiple wildcards in an absolute URL
|
||||
https://*.pages.dev/*
|
||||
x-custom: value
|
||||
# Multiple wildcards in a relative path
|
||||
/blog/*/posts/*
|
||||
x-custom: value
|
||||
# Single wildcard is fine
|
||||
https://*.pages.dev/
|
||||
x-custom: value
|
||||
/blog/*
|
||||
x-custom: value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "https://*.pages.dev/",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "/blog/*",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "https://*.pages.dev/*",
|
||||
lineNumber: 3,
|
||||
message:
|
||||
"Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping https://*.pages.dev/*.",
|
||||
},
|
||||
{
|
||||
line: "/blog/*/posts/*",
|
||||
lineNumber: 6,
|
||||
message:
|
||||
"Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping /blog/*/posts/*.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject paths combining wildcard with :splat placeholder", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# Wildcard + :splat in an absolute URL
|
||||
https://*.pages.dev/:splat
|
||||
x-custom: value
|
||||
# Wildcard + :splat in a relative path
|
||||
/blog/*/:splat
|
||||
x-custom: value
|
||||
# Just :splat without wildcard is fine
|
||||
/blog/:splat
|
||||
x-custom: value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "/blog/:splat",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "https://*.pages.dev/:splat",
|
||||
lineNumber: 3,
|
||||
message:
|
||||
"Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping https://*.pages.dev/:splat.",
|
||||
},
|
||||
{
|
||||
line: "/blog/*/:splat",
|
||||
lineNumber: 6,
|
||||
message:
|
||||
"Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping /blog/*/:splat.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject malformed URLs", ({ expect }) => {
|
||||
const input = `
|
||||
# Spaces should be URI encoded
|
||||
/some page with spaces
|
||||
valid: yup
|
||||
# OK with URL escaped encoding
|
||||
/some%20page
|
||||
valid: yup
|
||||
# Unescaped URLs are handled OK by Deno, so escape & pass them through
|
||||
/://so;\`me
|
||||
valid: yup
|
||||
/nons:/&@%+~{}ense
|
||||
valid: yup
|
||||
# Absolute URLs with a non-https protocol should be rejected
|
||||
https://yeah.com
|
||||
valid: yup
|
||||
http://nah.com/blog
|
||||
invalid: things
|
||||
# Absolute URLs with a port should be rejected
|
||||
https://nah.com:8080
|
||||
invalid: also
|
||||
https://nah.com:8080/blog
|
||||
invalid: 2
|
||||
//yeah.com/blog
|
||||
valid: things
|
||||
/yeah
|
||||
valid: yup
|
||||
/yeah.com
|
||||
valid: yup
|
||||
# Anything standalone is interpreted as a invalid header pair
|
||||
nah.com
|
||||
:
|
||||
test:
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
invalid: [
|
||||
{
|
||||
line: "http://nah.com/blog",
|
||||
lineNumber: 16,
|
||||
message:
|
||||
'URLs should either be relative (e.g. begin with a forward-slash), or use HTTPS (e.g. begin with "https://").',
|
||||
},
|
||||
{
|
||||
line: "https://nah.com:8080",
|
||||
lineNumber: 19,
|
||||
message:
|
||||
"Specifying ports is not supported. Skipping absolute URL https://nah.com:8080.",
|
||||
},
|
||||
{
|
||||
line: "https://nah.com:8080/blog",
|
||||
lineNumber: 21,
|
||||
message:
|
||||
"Specifying ports is not supported. Skipping absolute URL https://nah.com:8080/blog.",
|
||||
},
|
||||
{
|
||||
line: "nah.com",
|
||||
lineNumber: 30,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{ line: ":", lineNumber: 31, message: "No header name specified" },
|
||||
{ line: "test:", lineNumber: 32, message: "No header value specified" },
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
path: "/some%20page%20with%20spaces",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/some%20page", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{ path: "/://so;%60me", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "/nons:/&@%+~%7B%7Dense",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "https://yeah.com/",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "//yeah.com/blog",
|
||||
headers: { valid: "things" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/yeah", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{ path: "/yeah.com", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { test } from "vitest";
|
||||
import { parseHeaders } from "../configuration/parseHeaders";
|
||||
|
||||
test("parseHeaders should handle a single rule", ({ expect }) => {
|
||||
const input = `/a
|
||||
Name: Value`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should handle headers with exclamation marks", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a
|
||||
!Name: Value`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { "!name": "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should ignore blank lines", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
|
||||
Name: Value
|
||||
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should trim whitespace", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
Name : Value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should ignore comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This is a comment
|
||||
/a
|
||||
# And one here too.
|
||||
Name: Value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should combine headers together", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
Set-Cookie: test=cookie; expires=never
|
||||
Set-Cookie: another=cookie; magic!
|
||||
|
||||
/b
|
||||
A: ABBA
|
||||
B: BABA
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "/a",
|
||||
headers: {
|
||||
"set-cookie": "test=cookie; expires=never, another=cookie; magic!",
|
||||
},
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "/b",
|
||||
headers: { a: "ABBA", b: "BABA" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should support setting hosts", ({ expect }) => {
|
||||
const input = `
|
||||
https://example.com
|
||||
a: a
|
||||
https://example.com/
|
||||
b: b
|
||||
https://example.com/blog
|
||||
c: c
|
||||
/blog
|
||||
d:d
|
||||
https://:subdomain.example.*/path
|
||||
e:e
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "https://example.com/", headers: { a: "a" }, unsetHeaders: [] },
|
||||
{ path: "https://example.com/", headers: { b: "b" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "https://example.com/blog",
|
||||
headers: { c: "c" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/blog", headers: { d: "d" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "https://:subdomain.example.*/path",
|
||||
headers: { e: "e" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should add unset headers", ({ expect }) => {
|
||||
const input = `/a
|
||||
Name: Value
|
||||
! Place`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: ["Place"] },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should support custom limits", ({ expect }) => {
|
||||
const aaa = Array(1001).fill("a").join("");
|
||||
const bbb = Array(1001).fill("b").join("");
|
||||
const huge_line = `${aaa}: ${bbb}`;
|
||||
let input = `
|
||||
# Valid entry
|
||||
/a
|
||||
Name: Value
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
/b
|
||||
Name: Value
|
||||
${huge_line}
|
||||
`;
|
||||
let result = parseHeaders(input, { maxLineLength: 3000 });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { name: "Value", [aaa]: bbb }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}\nx-index: ${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseHeaders(input, { maxRules: 200 });
|
||||
expect(result.rules.length).toBe(150);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { test } from "vitest";
|
||||
import { parseRedirects } from "../configuration/parseRedirects";
|
||||
|
||||
// Snapshot values
|
||||
const maxDynamicRedirectRules = 100;
|
||||
const maxLineLength = 2000;
|
||||
const maxStaticRedirectRules = 2000;
|
||||
|
||||
test("parseRedirects should reject malformed lines", ({ expect }) => {
|
||||
const input = `
|
||||
# Single token
|
||||
/c
|
||||
# Four tokens
|
||||
/d /e 302 !important
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: `/c`,
|
||||
lineNumber: 3,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 1.",
|
||||
},
|
||||
{
|
||||
line: `/d /e 302 !important`,
|
||||
lineNumber: 5,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 4.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject invalid status codes", ({ expect }) => {
|
||||
const input = `
|
||||
# Valid token sails through
|
||||
/a /b 301
|
||||
# 418 NOT OK
|
||||
/c /d 418
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 3 }],
|
||||
invalid: [
|
||||
{
|
||||
line: `/c /d 418`,
|
||||
lineNumber: 5,
|
||||
message:
|
||||
"Valid status codes are 200, 301, 302 (default), 303, 307, or 308. Got 418.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject duplicate 'from' paths`, ({ expect }) => {
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Nonsensical but permitted (for now)
|
||||
/b /a
|
||||
# Duplicate 'from'
|
||||
/a /c
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 3 },
|
||||
{ from: "/b", status: 302, to: "/a", lineNumber: 5 },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: `/a /c`,
|
||||
lineNumber: 7,
|
||||
message: `Ignoring duplicate rule for path /a.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject lines longer than ${maxLineLength} chars`, ({
|
||||
expect,
|
||||
}) => {
|
||||
const huge_line = `/${Array(maxLineLength).fill("a").join("")} /${Array(
|
||||
maxLineLength
|
||||
)
|
||||
.fill("b")
|
||||
.join("")} 301`;
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(maxLineLength + 1)
|
||||
.fill("#")
|
||||
.join("")}
|
||||
# Huge path names rejected
|
||||
${huge_line}
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 3 }],
|
||||
invalid: [
|
||||
{
|
||||
message: `Ignoring line 7 as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject any dynamic rules after the first 100", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: Array(100)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}/*`,
|
||||
to: `/b/${i}/:splat`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
invalid: [
|
||||
{
|
||||
message: `Maximum number of dynamic rules supported is 100. Skipping remaining 52 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject any static rules after the first ${maxStaticRedirectRules}`, ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(maxStaticRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: Array(maxStaticRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}`,
|
||||
to: `/b/${i}`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
invalid: Array(50)
|
||||
.fill(undefined)
|
||||
.map(() => ({
|
||||
message: `Maximum number of static rules supported is ${maxStaticRedirectRules}. Skipping line.`,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject a combination of lots of static and dynamic rules", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(maxStaticRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
${Array(maxDynamicRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: [
|
||||
...Array(maxStaticRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}`,
|
||||
to: `/b/${i}`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
...Array(maxDynamicRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}/*`,
|
||||
to: `/b/${i}/:splat`,
|
||||
status: 302,
|
||||
lineNumber: i + maxStaticRedirectRules + 53,
|
||||
})),
|
||||
],
|
||||
invalid: [
|
||||
...Array(50)
|
||||
.fill(undefined)
|
||||
.map(() => ({
|
||||
message: `Maximum number of static rules supported is ${maxStaticRedirectRules}. Skipping line.`,
|
||||
})),
|
||||
{
|
||||
message: `Maximum number of dynamic rules supported is ${maxDynamicRedirectRules}. Skipping remaining 52 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject malformed URLs", ({ expect }) => {
|
||||
const input = `
|
||||
# Spaces rejected on token length
|
||||
/some page /somewhere else
|
||||
# OK with URL escaped encoding
|
||||
/some%20page /somewhere%20else
|
||||
# Unescaped URLs are handled OK by Deno, so escape & pass them through
|
||||
/://so;\`me /nons:/&@%+~{}ense
|
||||
# Absolute URLs aren't OK for 'from', but are fine for 'to'
|
||||
https://yeah.com https://nah.com
|
||||
/nah https://yeah.com
|
||||
# This is actually parsed as /yeah.com, which we might want to detect but is ok for now
|
||||
yeah.com https://nah.com
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
invalid: [
|
||||
{
|
||||
line: `/some page /somewhere else`,
|
||||
lineNumber: 3,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 4.",
|
||||
},
|
||||
{
|
||||
line: `https://yeah.com https://nah.com`,
|
||||
lineNumber: 9,
|
||||
message:
|
||||
"Only relative URLs are allowed. Skipping absolute URL https://yeah.com.",
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
from: "/some%20page",
|
||||
status: 302,
|
||||
to: "/somewhere%20else",
|
||||
lineNumber: 5,
|
||||
},
|
||||
{
|
||||
from: "/://so;%60me",
|
||||
status: 302,
|
||||
to: "/nons:/&@%+~%7B%7Dense",
|
||||
lineNumber: 7,
|
||||
},
|
||||
{ from: "/nah", status: 302, to: "https://yeah.com/", lineNumber: 10 },
|
||||
{
|
||||
from: "/yeah.com",
|
||||
status: 302,
|
||||
to: "https://nah.com/",
|
||||
lineNumber: 12,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject non-relative URLs for proxying (200) redirects", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a https://example.com/b 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: `/a https://example.com/b 200`,
|
||||
lineNumber: 2,
|
||||
message:
|
||||
"Proxy (200) redirects can only point to relative paths. Got https://example.com/b",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject wildcard patterns to index", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/* /index.html 200
|
||||
/* /index 200
|
||||
/ /index.html
|
||||
/ /index
|
||||
`;
|
||||
const invalidRedirectError =
|
||||
"Infinite loop detected in this rule and has been ignored. This will cause a redirect to strip `.html` or `/index` and end up triggering this rule again. Please fix or remove this rule to silence this warning.";
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: "/* /index.html 200",
|
||||
lineNumber: 2,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/* /index 200",
|
||||
lineNumber: 3,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index.html",
|
||||
lineNumber: 4,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index",
|
||||
lineNumber: 5,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should allow root patterns to index when HTML handling disabled", ({
|
||||
expect,
|
||||
}) => {
|
||||
// This test documents the fix for https://github.com/cloudflare/workers-sdk/issues/11824
|
||||
// Exact path matches like "/ /index.html" are valid when html_handling is "none"
|
||||
// because there's no automatic index.html serving, so no infinite loop occurs.
|
||||
// However, wildcard patterns like "/* /index.html" should still be blocked.
|
||||
const input = `
|
||||
/* /index.html 200
|
||||
/* /index 200
|
||||
/ /index.html
|
||||
/ /index
|
||||
`;
|
||||
const invalidRedirectError =
|
||||
"Infinite loop detected in this rule and has been ignored. This will cause a redirect to strip `.html` or `/index` and end up triggering this rule again. Please fix or remove this rule to silence this warning.";
|
||||
const result = parseRedirects(input, { htmlHandling: "none" });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/",
|
||||
status: 302,
|
||||
to: "/index.html",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "/* /index.html 200",
|
||||
lineNumber: 2,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/* /index 200",
|
||||
lineNumber: 3,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index",
|
||||
lineNumber: 5,
|
||||
message: "Ignoring duplicate rule for path /.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { test } from "vitest";
|
||||
import { parseRedirects } from "../configuration/parseRedirects";
|
||||
|
||||
test("parseRedirects should handle a single rule", ({ expect }) => {
|
||||
const input = `/a /b 301`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should ignore blank lines", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 301
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should trim whitespace", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 301
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should ignore comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This is a comment
|
||||
/a /b 301
|
||||
# And one here too.
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 3 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle a single comment-only line", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `# This is just a comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle an indented comment-only line", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = ` # indented comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle multiple consecutive comment lines", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# First comment
|
||||
# Second comment
|
||||
# Indented comment
|
||||
/a /b 301
|
||||
# Comment after rule
|
||||
# Another comment
|
||||
/c /d
|
||||
# Final comment
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 301, to: "/b", lineNumber: 5 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 8 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle a file with only comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This file has no redirects
|
||||
# Just comments
|
||||
# Some indented
|
||||
# And more comments
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should default to 302", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 302
|
||||
/c /d
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 2 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 3 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve querystrings on to", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b?query=string 302
|
||||
/c?this=rejected /d 301
|
||||
/ext https://some.domain:1234/route?q=string#anchor
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b?query=string", lineNumber: 2 },
|
||||
{ from: "/c", status: 301, to: "/d", lineNumber: 3 },
|
||||
{
|
||||
from: "/ext",
|
||||
status: 302,
|
||||
to: "https://some.domain:1234/route?q=string#anchor",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b#blah 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b#blah", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments which contain a hash sign", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a /b##blah-1 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b##blah-1", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments which contain a hash sign and are full URLs", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a https://example.com/b##blah-1 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/a",
|
||||
status: 302,
|
||||
to: "https://example.com/b##blah-1",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept 200 (proxying) redirects", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/a",
|
||||
status: 200,
|
||||
to: "/b",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept absolute URLs that end with index.html", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/foo https://bar.com/index.html 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/foo",
|
||||
status: 302,
|
||||
to: "https://bar.com/index.html",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept going to absolute URLs with ports", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/foo https://bar.com:123/index.html 302
|
||||
/cat https://cat.com:12345 302
|
||||
/dog https://dog.com:12345
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/foo",
|
||||
status: 302,
|
||||
to: "https://bar.com:123/index.html",
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
from: "/cat",
|
||||
status: 302,
|
||||
to: "https://cat.com:12345/",
|
||||
lineNumber: 3,
|
||||
},
|
||||
{
|
||||
from: "/dog",
|
||||
status: 302,
|
||||
to: "https://dog.com:12345/",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept relative URLs that don't point to .html files", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/* /foo 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/*",
|
||||
status: 200,
|
||||
to: "/foo",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments", ({ expect }) => {
|
||||
const input = `/a /b 301 # redirect a to b`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments without status code", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b # redirect with default status`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments after URL fragments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b#section 301 # redirect to section`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b#section", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments without space after hash", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b 301 #no space comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support multiple rules with inline comments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a /b 301 # first rule
|
||||
/c /d # second rule with default status
|
||||
# full line comment
|
||||
/e /f 307 # third rule
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 301, to: "/b", lineNumber: 2 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 3 },
|
||||
{ from: "/e", status: 307, to: "/f", lineNumber: 5 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments with absolute URLs containing fragments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a https://x.com/b#c # comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "https://x.com/b#c", lineNumber: 1 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support empty inline comments (just hash)", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b #`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments with multiple hashes in URL fragment", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b##anchor # comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b##anchor", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support custom limits", ({ expect }) => {
|
||||
const aaa = Array(1001).fill("a").join("");
|
||||
const bbb = Array(1001).fill("b").join("");
|
||||
const huge_line = `/${aaa} /${bbb} 301`;
|
||||
let input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
${huge_line}
|
||||
`;
|
||||
let result = parseRedirects(input, { maxLineLength: 3000 });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 3 },
|
||||
{ from: `/${aaa}`, status: 301, to: `/${bbb}`, lineNumber: 7 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, { maxDynamicRules: 200 });
|
||||
expect(result.rules.length).toBe(150);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(2050)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, { maxStaticRules: 3000 });
|
||||
expect(result.rules.length).toBe(2050);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(2050)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, {
|
||||
maxDynamicRules: 200,
|
||||
maxStaticRules: 3000,
|
||||
});
|
||||
expect(result.rules.length).toBe(2200);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { parseStaticRouting } from "../configuration/parseStaticRouting";
|
||||
|
||||
describe("parseStaticRouting", () => {
|
||||
it("throws when given empty rules", ({ expect }) => {
|
||||
expect(() => parseStaticRouting([])).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: No \`run_worker_first\` rules were provided; must provide at least 1 rule.]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when given only negative rules", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting(["!/assets"])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Only negative \`run_worker_first\` rules were provided; must provide at least 1 non-negative rule]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when too many rules are provided", ({ expect }) => {
|
||||
const rules = Array.from({ length: 120 }, (_, i) => `/rule/${i}`);
|
||||
expect(() => parseStaticRouting(rules)).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Too many \`run_worker_first\` rules were provided; 120 rules provided exceeds max of 100.]`
|
||||
);
|
||||
|
||||
const userWorkerRules = Array.from({ length: 60 }, (_, i) => `/rule/${i}`);
|
||||
const assetRules = Array.from({ length: 60 }, (_, i) => `!/rule/${60 + i}`);
|
||||
expect(() =>
|
||||
parseStaticRouting([...userWorkerRules, ...assetRules])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Too many \`run_worker_first\` rules were provided; 120 rules provided exceeds max of 100.]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a rule is too long", ({ expect }) => {
|
||||
const rule = `/api/${"a".repeat(130)}`;
|
||||
expect(() => parseStaticRouting([rule])).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': all rules must be less than 100 characters in length]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when rule doesn't begin with /", ({ expect }) => {
|
||||
expect(() => parseStaticRouting(["api/*", "!asset"]))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'api/*': rules must start with '/' or '!/'
|
||||
'!asset': negative rules must start with '!/']
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws when given redundant rules", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting([
|
||||
"/api/*",
|
||||
"/oauth/callback",
|
||||
"/api/some/route",
|
||||
"!/api/assets/*",
|
||||
])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/some/route': rule '/api/*' makes it redundant]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when given duplicate routes", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting([
|
||||
"/api/some/route",
|
||||
"/oauth/callback",
|
||||
"/api/some/route",
|
||||
"!/api/assets/*",
|
||||
])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/some/route': rule is a duplicate; rules must be unique]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("correctly parses valid rules", ({ expect }) => {
|
||||
const parsed = parseStaticRouting([
|
||||
"/api/*",
|
||||
"/oauth/callback",
|
||||
"!/api/assets/*",
|
||||
]);
|
||||
const expected = {
|
||||
user_worker: ["/api/*", "/oauth/callback"],
|
||||
asset_worker: ["/api/assets/*"],
|
||||
};
|
||||
expect(parsed).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { JaegerTracing, Span } from "./types";
|
||||
|
||||
export function mockJaegerBindingSpan(): Span {
|
||||
return {
|
||||
addLogs: () => {},
|
||||
setTags: () => {},
|
||||
end: () => {},
|
||||
isRecording: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockJaegerBinding(): JaegerTracing {
|
||||
return {
|
||||
enterSpan: (_, span, ...args) => {
|
||||
return span(mockJaegerBindingSpan(), ...args);
|
||||
},
|
||||
getSpanContext: () => ({
|
||||
traceId: "test-trace",
|
||||
spanId: "test-span",
|
||||
parentSpanId: "test-parent-span",
|
||||
traceFlags: 0,
|
||||
}),
|
||||
runWithSpanContext: (_, callback, ...args) => {
|
||||
return callback(...args);
|
||||
},
|
||||
|
||||
traceId: "test-trace",
|
||||
spanId: "test-span",
|
||||
parentSpanId: "test-parent-span",
|
||||
cfTraceIdHeader: "test-trace:test-span:0",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["@cloudflare/workers-types/experimental", "@types/node"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const InternalConfigSchema = z.object({
|
||||
account_id: z.number().optional(),
|
||||
script_id: z.number().optional(),
|
||||
debug: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const StaticRoutingSchema = z.object({
|
||||
user_worker: z.array(z.string()),
|
||||
asset_worker: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type StaticRouting = z.infer<typeof StaticRoutingSchema>;
|
||||
|
||||
export const RouterConfigSchema = z.object({
|
||||
invoke_user_worker_ahead_of_assets: z.boolean().optional(),
|
||||
static_routing: StaticRoutingSchema.optional(),
|
||||
has_user_worker: z.boolean().optional(),
|
||||
...InternalConfigSchema.shape,
|
||||
});
|
||||
|
||||
export const EyeballRouterConfigSchema = z.union([
|
||||
z.object({
|
||||
limitedAssetsOnly: z.boolean().optional(),
|
||||
}),
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
const MetadataStaticRedirectEntry = z.object({
|
||||
status: z.number(),
|
||||
to: z.string(),
|
||||
lineNumber: z.number(),
|
||||
});
|
||||
|
||||
const MetadataRedirectEntry = z.object({
|
||||
status: z.number(),
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
const MetadataStaticRedirects = z.record(MetadataStaticRedirectEntry);
|
||||
export type MetadataStaticRedirects = z.infer<typeof MetadataStaticRedirects>;
|
||||
const MetadataRedirects = z.record(MetadataRedirectEntry);
|
||||
export type MetadataRedirects = z.infer<typeof MetadataRedirects>;
|
||||
|
||||
const MetadataHeaderEntry = z.object({
|
||||
set: z.record(z.string()).optional(),
|
||||
unset: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const MetadataHeaders = z.record(MetadataHeaderEntry);
|
||||
export type MetadataHeaders = z.infer<typeof MetadataHeaders>;
|
||||
|
||||
export const RedirectsSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
staticRules: MetadataStaticRedirects,
|
||||
rules: MetadataRedirects,
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const HeadersSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
rules: MetadataHeaders,
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const AssetConfigSchema = z.object({
|
||||
compatibility_date: z.string().optional(),
|
||||
compatibility_flags: z.array(z.string()).optional(),
|
||||
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(),
|
||||
redirects: RedirectsSchema,
|
||||
headers: HeadersSchema,
|
||||
has_static_routing: z.boolean().optional(),
|
||||
...InternalConfigSchema.shape,
|
||||
});
|
||||
|
||||
export type EyeballRouterConfig = z.infer<typeof EyeballRouterConfigSchema>;
|
||||
export type RouterConfig = z.infer<typeof RouterConfigSchema>;
|
||||
export type AssetConfig = z.infer<typeof AssetConfigSchema>;
|
||||
|
||||
export interface UnsafePerformanceTimer {
|
||||
readonly timeOrigin: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export interface JaegerTracing {
|
||||
enterSpan<T extends unknown[], R = void>(
|
||||
name: string,
|
||||
span: (s: Span, ...args: T) => R,
|
||||
...args: T
|
||||
): R;
|
||||
getSpanContext(): SpanContext | null;
|
||||
runWithSpanContext<T extends unknown[]>(
|
||||
spanContext: SpanContext | null,
|
||||
callback: (...args: T) => unknown,
|
||||
...args: T
|
||||
): unknown;
|
||||
|
||||
readonly traceId: string | null;
|
||||
readonly spanId: string | null;
|
||||
readonly parentSpanId: string | null;
|
||||
readonly cfTraceIdHeader: string | null;
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
addLogs(logs: JaegerRecord): void;
|
||||
setTags(tags: JaegerRecord): void;
|
||||
end(): void;
|
||||
|
||||
isRecording: boolean;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
traceFlags: number;
|
||||
}
|
||||
|
||||
export type JaegerValue = string | number | boolean;
|
||||
export type JaegerRecord = Record<string, JaegerValue>;
|
||||
|
||||
export interface ColoMetadata {
|
||||
metalId: number;
|
||||
coloId: number;
|
||||
coloRegion: string;
|
||||
coloTier: number;
|
||||
}
|
||||
Reference in New Issue
Block a user