commit 70bf21e064dcea4aaf94b04aae91a0b87592e471 Author: wehub-resource-sync Date: Mon Jul 13 12:30:11 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..4177918 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,127 @@ +# Changesets + +Every non-trivial change to the project - those that should appear in the changelog - must be captured in a "changeset". + +We use the [`changesets`](https://github.com/changesets/changesets/blob/main/README.md) tool for creating changesets, publishing versions and updating the changelog. + +## Creating a Changeset + +```sh +pnpm changeset +``` + +1. Select which packages are affected by the change +2. Choose whether the version requires a major, minor, or patch release +3. Write a description of the change (see format below) +4. Include the generated changeset in your commit: + ```sh + git add .changeset/*.md + ``` + +## Version Types + +- **patch**: Bug fixes, small improvements, documentation fixes +- **minor**: New features, new CLI commands, new configuration options, deprecations, and changes to experimental/beta/pre-1.0 features (including breaking changes to those features). When adding or changing experimental features, call this out explicitly in the changeset description. +- **major**: Breaking changes to stable features (when deprecations take effect, or functional breaking behavior is added). Note: breaking changes to experimental/beta features do NOT require a major version bump. + +**Important restrictions:** + +- Major versions for `wrangler` are currently **forbidden**. This rule will be removed when we are preparing for the next major release of `wrangler`. +- Major versions for other packages require strong justification +- If the change collects more analytics, it should be a minor even though there is no user-visible change. + +## Changeset Message Format + +``` + + +<BODY> +``` + +- **TITLE**: A single sentence with an imperative description of the change +- **BODY**: One or more paragraphs explaining the reason for the change and anything notable about the approach. Aim for more than one sentence but less than three paragraphs to keep it succinct and useful. Larger changes may warrant more detail. + +### Good Examples + +For a new feature (minor): + +```markdown +--- +"wrangler": minor +--- + +Add `wrangler d1 export` command for exporting D1 databases to SQL files + +You can now export your D1 database to a local SQL file: + +`wrangler d1 export my-database --output backup.sql` + +This is useful for creating backups or migrating data between databases. +``` + +For a bug fix (patch): + +```markdown +--- +"wrangler": patch +--- + +Fix `wrangler dev` failing to start when `wrangler.toml` contains Unicode characters + +Previously, projects with non-ASCII characters in configuration values would fail with "Invalid UTF-8 sequence". This is now handled correctly. +``` + +### Bad Examples (avoid these) + +- "fix bug" - What bug? What was the symptom? +- "update dependency" - Which one? Why? Any user impact? +- "Add new feature" - What feature? How do you use it? +- "refactor" - Why does this warrant a release? What's the user impact? + +## Formatting Rules + +### Markdown Headers + +Changeset descriptions must **NOT** use h1 (`#`), h2 (`##`), or h3 (`###`) headers. + +The changelog uses h3 for section headers, so any headers in changeset content must be h4 (`####`) or smaller. This prevents formatting issues in the generated changelog. + +### Code Examples + +For new features or significant changes, consider including a brief usage example. Examples can be helpful for users to understand new functionality, but they are not mandatory—use your judgment based on how self-explanatory the change is. + +When showing Wrangler configuration examples, use `wrangler.json` (with JSONC syntax for comments) rather than `wrangler.toml`. + +## Multiple Changesets + +If your PR makes multiple distinct user-facing changes, create separate changesets so each gets its own changelog entry. Don't lump unrelated changes together, and don't mix different types of changes (e.g., bug fix + new feature) in a single changeset. + +## Package Coverage + +Each changeset should reference all packages that have user-facing changes: + +- If a change affects multiple packages, list them all in the changeset +- Alternatively, create separate changesets for each package if the changes warrant different descriptions +- You do NOT need to include packages that will only be released because they depend on a changed package - changesets handles this automatically + +## When a Changeset is NOT Required + +- Changes that are purely internal refactoring with no user-facing impact +- Changes only to devDependencies +- Documentation-only changes within a package +- Test-only changes +- CI/workflow changes that don't affect package behavior + +## File Example + +Here's a complete example of a patch changeset file: + +```markdown +--- +"wrangler": patch +--- + +Replace the word "publish" with "deploy" everywhere + +We should be consistent with the word that describes how we get a worker to the edge. The command is `deploy`, so let's use that everywhere. +``` diff --git a/.changeset/bright-doors-evict.md b/.changeset/bright-doors-evict.md new file mode 100644 index 0000000..06f0ed8 --- /dev/null +++ b/.changeset/bright-doors-evict.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Add `unsafeEvictDurableObject()` for targeted Durable Object eviction + +This lets users verify how a Durable Object recovers after its instance is torn down. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..12248e5 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@1.6.2/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "cloudflare/workers-sdk" } + ], + "commit": false, + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "bumpVersionsWithWorkspaceProtocolOnly": true, + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + }, + "ignore": [], + "privatePackages": { + "tag": true, + "version": true + } +} diff --git a/.changeset/fix-schema-editor-dirty-state.md b/.changeset/fix-schema-editor-dirty-state.md new file mode 100644 index 0000000..eae0ed4 --- /dev/null +++ b/.changeset/fix-schema-editor-dirty-state.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/local-explorer-ui": patch +--- + +Fix D1 schema editor tab not tracking unsaved changes + +The schema editor tab (edit table / create table) now correctly marks the tab as dirty when there are unsaved schema changes. This shows the unsaved changes indicator on the tab, triggers the browser's leave guard when navigating away, and prompts for confirmation when closing the tab. + +Additionally, column and constraint deletions now properly mark the schema as dirty. Previously, removing a column or constraint would filter the entry out of the state array entirely, causing the dirty-state check to miss the change. diff --git a/.changeset/fix-tanstack-no-install.md b/.changeset/fix-tanstack-no-install.md new file mode 100644 index 0000000..00d743c --- /dev/null +++ b/.changeset/fix-tanstack-no-install.md @@ -0,0 +1,7 @@ +--- +"create-cloudflare": patch +--- + +Pass `--no-install` to `@tanstack/cli create` during scaffolding + +Previously, the TanStack Start template did not pass `--no-install` to the framework CLI, causing dependencies to be installed twice: once by `@tanstack/cli create` and again by C3's own install step. This aligns the TanStack Start template with other framework templates that already skip the framework CLI's install. diff --git a/.changeset/gentle-lions-list-do-ids.md b/.changeset/gentle-lions-list-do-ids.md new file mode 100644 index 0000000..dc60f59 --- /dev/null +++ b/.changeset/gentle-lions-list-do-ids.md @@ -0,0 +1,5 @@ +--- +"miniflare": minor +--- + +Allow `listDurableObjectIds()` to accept Durable Object class names as well as binding names. diff --git a/.changeset/green-badgers-evict.md b/.changeset/green-badgers-evict.md new file mode 100644 index 0000000..9589046 --- /dev/null +++ b/.changeset/green-badgers-evict.md @@ -0,0 +1,12 @@ +--- +"wrangler": minor +--- + +Add Durable Object eviction support to `createTestHarness` + +You can now gracefully evict a running Durable Object by class name or binding name to verify how it recovers after its instance is torn down: + +```ts +const worker = server.getWorker(); +await worker.evictDurableObject("Counter", { name: "user-123" }); +``` diff --git a/.changeset/lazy-register-hooks.md b/.changeset/lazy-register-hooks.md new file mode 100644 index 0000000..c4d65a1 --- /dev/null +++ b/.changeset/lazy-register-hooks.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Fix load time crash on Node.js versions earlier than 22.15 + +The plugin eagerly imported `registerHooks` from `node:module`, which only exists on Node.js v22.15.0+. `registerHooks` is now read lazily, meaning that missing support is only surfaced when using `experimental.newConfig`. diff --git a/.changeset/pages-delegation-force-notice.md b/.changeset/pages-delegation-force-notice.md new file mode 100644 index 0000000..1b0a4db --- /dev/null +++ b/.changeset/pages-delegation-force-notice.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Improve the agent-facing `--force` guidance for Pages-to-Workers delegation + +When an AI agent opts out of the Pages-to-Workers delegation by passing `--force` to `wrangler pages deploy` or `wrangler pages project create`, Wrangler now prints a notice at the end of a successful command explaining that `--force` only needs to be passed once: the project then exists, so subsequent commands are no longer delegated and do not need the flag. diff --git a/.changeset/remove-service-environments.md b/.changeset/remove-service-environments.md new file mode 100644 index 0000000..e7fd8e3 --- /dev/null +++ b/.changeset/remove-service-environments.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Remove support for service environments and the `legacy_env` configuration field + +Service environments have been removed. Wrangler now always deploys each environment as its own Worker named `<name>-<environment>`, which matches the behaviour of the previous default (`legacy_env = true`). The `--legacy-env` CLI flag has been removed, and the `legacy_env` configuration field is no longer supported — including it in your configuration file will now raise an error. + +Because `legacy_env = true` was already the default, removing the field will not change how your Worker is deployed. If you were relying on service environments (`legacy_env = false`), each environment will now be deployed as a standalone Worker instead of as an environment of a single Worker. See https://developers.cloudflare.com/workers/wrangler/environments/ for more information. diff --git a/.changeset/suggest-similar-commands-on-typo.md b/.changeset/suggest-similar-commands-on-typo.md new file mode 100644 index 0000000..d4eb587 --- /dev/null +++ b/.changeset/suggest-similar-commands-on-typo.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Suggest similar commands when a typo is detected + +When an unknown command or subcommand is entered, wrangler now suggests the closest matching command if one exists within a reasonable edit distance. For example, running `wrangler whoamio` will display `Did you mean "wrangler whoami"?`, and running `wrangler kv namespase` will display `Did you mean "wrangler kv namespace"?`. diff --git a/.codeowners b/.codeowners new file mode 100644 index 0000000..1fce657 --- /dev/null +++ b/.codeowners @@ -0,0 +1,81 @@ +# ============================================================ +# Codeowners Plus - Code Ownership Rules +# ============================================================ +# This file defines code ownership for the workers-sdk monorepo, +# enforced by Codeowners Plus (https://github.com/multimediallc/codeowners-plus). +# +# See CODEOWNERS.md for full documentation on how ownership works. +# +# Syntax: +# (no prefix) = primary owner (highest-priority match wins) +# & prefix = AND rule (additional required reviewer) +# ? prefix = optional/CC reviewer (non-blocking) +# Multiple teams on one line = OR (either can satisfy) +# +# Rules are relative to this file's directory (repo root). +# Unlike GitHub CODEOWNERS, `*.js` only matches in this directory; +# use `**/*.js` for recursive matching. +# +# Paths not matching any specific rule fall through to the default +# primary owner (currently @cloudflare/wrangler). +# ============================================================ + +# Default owner - ANT/Wrangler team owns everything +* @cloudflare/wrangler + +# ---------------------------------------------------------- +# Versioning & release files (wrangler-only, no AND rules) +# ---------------------------------------------------------- +# Only wrangler team approval is needed for releases. +# Product teams can request hold on release by reaching out. +**/CHANGELOG.md @cloudflare/wrangler +**/package.json @cloudflare/wrangler + +# ---------------------------------------------------------- +# D&C ownership (AND: requires wrangler + deploy-config) +# ---------------------------------------------------------- +# */** excludes root-level files (CHANGELOG.md, package.json) +# so releases only need wrangler approval +& packages/workers-shared/*/** @cloudflare/deploy-config + +# ---------------------------------------------------------- +# D1 ownership (AND: requires wrangler + d1) +# ---------------------------------------------------------- +& packages/wrangler/src/api/d1/** @cloudflare/d1 +& packages/wrangler/src/d1/** @cloudflare/d1 +& packages/wrangler/src/__tests__/d1/** @cloudflare/d1 + +# ---------------------------------------------------------- +# Cloudchamber ownership (AND: requires wrangler + cloudchamber) +# ---------------------------------------------------------- +& packages/wrangler/src/cloudchamber/** @cloudflare/cloudchamber +& packages/wrangler/src/containers/** @cloudflare/cloudchamber +& packages/wrangler/src/__tests__/containers/** @cloudflare/cloudchamber +# */** excludes root-level files (CHANGELOG.md, package.json) +& packages/containers-shared/*/** @cloudflare/cloudchamber + +# ---------------------------------------------------------- +# Workers KV ownership (AND: requires wrangler + workers-kv) +# ---------------------------------------------------------- +& packages/wrangler/src/kv/** @cloudflare/workers-kv +& packages/wrangler/src/__tests__/kv/** @cloudflare/workers-kv +& packages/miniflare/src/workers/kv/** @cloudflare/workers-kv +& packages/miniflare/test/plugins/kv/** @cloudflare/workers-kv + +# ---------------------------------------------------------- +# Workflows ownership (AND: requires wrangler + workflows) +# ---------------------------------------------------------- +# */** excludes root-level files (CHANGELOG.md, package.json) +& packages/workflows-shared/*/** @cloudflare/workflows + +# ---------------------------------------------------------- +# Adding a new product team +# ---------------------------------------------------------- +# Copy this template and fill in <feature> and <team>: +# +# # Product: <Name> (AND: requires wrangler + <team>) +# & packages/wrangler/src/<feature>/** @cloudflare/<team> +# & packages/wrangler/src/__tests__/<feature>/** @cloudflare/<team> +# & packages/miniflare/src/plugins/<feature>/** @cloudflare/<team> +# & packages/miniflare/src/workers/<feature>/** @cloudflare/<team> +# & packages/miniflare/test/plugins/<feature>/** @cloudflare/<team> diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e838df0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# https://editorconfig.org +root = true + +[*] +end_of_line = lf +indent_style = tab + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..d26ca6a --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# Refactor from spaces to tabs +e5a6aca696108cda8c3890b8ce2ec44c6cc09a0e +# Prettier 2 -> 3 +f6300a71adb83e5f92cd9c9ddd56a85132e03f98 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c3e3a80 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +api-extractor.json linguist-language=JSON-with-Comments +tsconfig.emit.json linguist-language=JSON-with-Comments diff --git a/.gitguardian.yml b/.gitguardian.yml new file mode 100644 index 0000000..681691a --- /dev/null +++ b/.gitguardian.yml @@ -0,0 +1,4 @@ +version: 2 +secret: + ignored-paths: + - "packages/wrangler/src/__tests__/hyperdrive.test.ts" diff --git a/.github/ISSUE_TEMPLATE/bug-template.yaml b/.github/ISSUE_TEMPLATE/bug-template.yaml new file mode 100644 index 0000000..f286a08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-template.yaml @@ -0,0 +1,35 @@ +name: "\U0001F41B Bug Report" +description: Report an issue or possible bug +type: Bug + +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to file a bug report! Please fill out this form as completely as possible. + - type: textarea + attributes: + label: What versions & operating system are you using? + description: "Make sure you've checked that you meet our system requirements as listed here: https://developers.cloudflare.com/workers/wrangler/install-and-update/. Please provide the output of running `npx envinfo --system --npmPackages '{wrangler,create-cloudflare,miniflare,@cloudflare/*}' --binaries`" + placeholder: e.g. Wrangler v4.6, Node v20, Windows 11 etc... + validations: + required: true + - type: input + attributes: + label: Please provide a link to a minimal reproduction + placeholder: https://github.com/foobar-user/minimal-repro + validations: + required: true + - type: textarea + attributes: + label: Describe the Bug + description: Please describe the bug in as much detail as possible, including steps to reproduce, the behaviour you're seeing, and the behaviour you expect. + validations: + required: true + + - type: textarea + attributes: + label: Please provide any relevant error logs + description: Although not required, we often request logs to help troubleshoot the issue, so providing this up-front streamlines the process towards resolution. Please be careful to hide any sensitive information. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fb7977d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Feature Request + url: https://github.com/cloudflare/workers-sdk/discussions/new?category=feature-requests + about: "Have a suggestion? Open a discussion to share your idea!" + - name: Support + url: https://discord.cloudflare.com + about: "Join us on Discord!" diff --git a/.github/actions/check-remote-tests/action.yml b/.github/actions/check-remote-tests/action.yml new file mode 100644 index 0000000..07f4802 --- /dev/null +++ b/.github/actions/check-remote-tests/action.yml @@ -0,0 +1,33 @@ +name: "Check Remote Tests" +description: > + Determines whether E2E tests should be given Cloudflare API credentials. + Returns true for merge-queue runs, Version Packages PRs, or when the + "ci:run-remote-tests" label is present on a pull request. +outputs: + run-remote: + description: "'true' when remote tests should run, 'false' otherwise" + value: ${{ steps.decide.outputs.run_remote }} +runs: + using: "composite" + steps: + - name: Decide whether to run remote tests + id: decide + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + if [ "$EVENT_NAME" = "merge_group" ] || \ + [ "$HEAD_REF" = "changeset-release/main" ]; then + echo "run_remote=true" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "pull_request" ]; then + HAS_LABEL=$(gh pr view "$PR_NUMBER" \ + --repo "$REPO" --json labels \ + --jq '[.labels[].name] | any(. == "ci:run-remote-tests")') + echo "run_remote=${HAS_LABEL}" >> "$GITHUB_OUTPUT" + else + echo "run_remote=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/expose-actions-variables/action.yml b/.github/actions/expose-actions-variables/action.yml new file mode 100644 index 0000000..a44eeb0 --- /dev/null +++ b/.github/actions/expose-actions-variables/action.yml @@ -0,0 +1,5 @@ +name: "Expose Actions Variables" +description: "Expose ACTIONS_RUNTIME_TOKEN and ACTIONS_RESULTS_URL on GITHUB_ENV" +runs: + using: "node20" + main: "index.mjs" diff --git a/.github/actions/expose-actions-variables/index.mjs b/.github/actions/expose-actions-variables/index.mjs new file mode 100644 index 0000000..ea6b525 --- /dev/null +++ b/.github/actions/expose-actions-variables/index.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; + +fs.appendFileSync( + process.env.GITHUB_ENV, + `GITHUB_ACTIONS_RUNTIME_TOKEN=${process.env.ACTIONS_RUNTIME_TOKEN}\n` + + `GITHUB_ACTIONS_RESULTS_URL=${process.env.ACTIONS_RESULTS_URL}\n` +); diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 0000000..59ca8a9 --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,62 @@ +name: "Install Dependencies" +description: "Install dependencies, fetching from cache when possible" +inputs: + # We run all jobs on Node.js 22 by default, as this is the most stable and supported version right now. + # + # Pinned to 22.23.1 (rather than floating `22`) until it's safe to float back: + # Node 22.23.0 regressed node-fetch@2 keep-alive responses with + # ERR_STREAM_PREMATURE_CLOSE (nodejs/node#63989, fixed in 22.23.1). + node-version: + description: the version of Node.js to install + default: 22.23.1 + turbo-api: + description: the api URL for connecting to the turbo remote cache + turbo-team: + description: the team identifier for connecting to the turbo remote cache + turbo-token: + description: the api token for connecting to the turbo remote cache + turbo-signature: + description: the cache signature key for connecting to the turbo remote cache + disable-cache: + description: when "true", skip the pnpm store cache on setup-node (defense in depth for release builds) + default: "false" +runs: + using: "composite" + steps: + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Install Node.js ${{ inputs.node-version }} (with pnpm cache) + if: inputs.disable-cache != 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + registry-url: "https://registry.npmjs.org" + + - name: Install Node.js ${{ inputs.node-version }} (without pnpm cache) + if: inputs.disable-cache == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + registry-url: "https://registry.npmjs.org" + + # Enable node compile cache (effective for Node 22+) + # See https://nodejs.org/docs/v24.11.1/api/module.html#module-compile-cache + - name: Enable Node Compile Cache + shell: bash + run: echo "NODE_COMPILE_CACHE=$HOME/.node_compile_cache" >> $GITHUB_ENV + + - name: "Configure turbo repo cache environment variables" + if: inputs.turbo-api + shell: bash + run: | + echo "TURBO_API=${{ inputs.turbo-api }}" >> $GITHUB_ENV + echo "TURBO_TEAM=${{ inputs.turbo-team }}" >> $GITHUB_ENV + echo "TURBO_TOKEN=${{ inputs.turbo-token }}" >> $GITHUB_ENV + echo "TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ inputs.turbo-signature }}" >> $GITHUB_ENV + echo "TURBO_ENV_MODE=loose" >> $GITHUB_ENV + + - name: Install NPM Dependencies + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/actions/install-python-uv/action.yml b/.github/actions/install-python-uv/action.yml new file mode 100644 index 0000000..53e991a --- /dev/null +++ b/.github/actions/install-python-uv/action.yml @@ -0,0 +1,9 @@ +name: "Install Python uv" +runs: + using: "composite" + steps: + - name: Install uv for Python + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.9.3" + enable-cache: false diff --git a/.github/bonk_reviewer.md b/.github/bonk_reviewer.md new file mode 100644 index 0000000..a2e2436 --- /dev/null +++ b/.github/bonk_reviewer.md @@ -0,0 +1,60 @@ +You are a **code reviewer**, not an author. You review pull requests for workers-sdk, which contains developer tooling for Cloudflare Workers. These instructions override any prior instructions about editing files or making code changes. + +## Restrictions -- you MUST follow these exactly + +Do NOT: + +- Edit, write, create, or delete any files -- use file editing tools (Write, Edit) under no circumstances +- Run `git commit`, `git push`, `git add`, `git checkout -b`, or any git write operation +- Approve or request changes on the PR -- only post review comments +- Flag formatting issues -- Prettier enforces style in this repo + +If you want to suggest a code change, post a `suggestion` comment instead of editing the file. + +## Output rules + +**Confirm you are acting on the correct issue or PR**. Verify that the issue or PR number matches what triggered you, and do not write comments or otherwise act on other issues or PRs unless explicitly instructed to. + +**If there are NO actionable issues:** Your ENTIRE response MUST be the four characters `LGTM` -- no greeting, no summary, no analysis, nothing before or after it. + +**If there ARE actionable issues:** Begin with "I'm Bonk, and I've done a quick review of your PR." Then: + +1. One-line summary of the changes. +2. A ranked list of issues (highest severity first). +3. For EVERY issue with a concrete fix, you MUST post it as a GitHub suggestion comment (see below). Do not describe a fix in prose when you can provide it as a suggestion. + +## How to post feedback + +You have write access to PR comments via the `gh` CLI. **Prefer the batch review approach** (one review with grouped comments) over posting individual comments. This produces a single notification and a cohesive review. + +### Batch review (recommended) + +Write a JSON file and submit it as a review. This is the most reliable method -- no shell quoting issues. + +````bash +cat > /tmp/review.json << 'REVIEW' +{ + "event": "COMMENT", + "body": "Review summary here.", + "comments": [ + { + "path": "src/workerd/api/example.c++", + "line": 42, + "side": "RIGHT", + "body": "Ownership issue -- `kj::Own` moved but still referenced:\n```suggestion\nauto result = kj::mv(owned);\n```" + } + ] +} +REVIEW +gh api repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews --input /tmp/review.json +```` + +Each comment needs `path`, `line`, `side`, and `body`. Use `suggestion` fences in `body` for applicable changes. + +- `side`: `"RIGHT"` for added or unchanged lines, `"LEFT"` for deleted lines +- For multi-line suggestions, add `start_line` and `start_side` to the comment object +- If `gh api` returns a 422 (wrong line number, stale commit), fall back to a top-level PR comment with `gh pr comment` instead of retrying + +## What counts as actionable + +Logic bugs, security issues (note a lot of this repo covers local development environments), backward compatibility violations, incorrect API behavior. Be pragmatic -- do not nitpick, do not flag subjective preferences. diff --git a/.github/changeset-version.js b/.github/changeset-version.js new file mode 100644 index 0000000..c5140cb --- /dev/null +++ b/.github/changeset-version.js @@ -0,0 +1,147 @@ +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const { execSync } = require("node:child_process"); + +// This script is used by the `release.yml` workflow to update the version of the packages being released. +// The standard step is only to run `changeset version` but this does not update the lockfile. +// So we also run `pnpm install`, which does this update. +// This is a workaround until this is handled automatically by `changeset version`. +// See https://github.com/changesets/changesets/issues/421. + +function getPkg(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} +function setPkg(filePath, newPkg) { + fs.writeFileSync(filePath, `${JSON.stringify(newPkg, null, "\t")}\n`); +} +function parseVersion(version) { + // Extract `<major>.<minor>.<patch>` from version (could be a constraint) + const match = /(\d+)\.(\d+)\.(\d+)/.exec(version); + assert(match !== null, `Expected ${version} to be <major>.<minor>.<patch>`); + return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]; +} + +const rootPath = path.resolve(__dirname, ".."); +const miniflarePath = path.join(rootPath, "packages/miniflare"); +const miniflarePkgPath = path.join(miniflarePath, "package.json"); + +function getWorkerdVersion() { + const pnpmWorkspacePath = path.join(rootPath, "pnpm-workspace.yaml"); + const match = /workerd: "(\d+\.\d+\.\d+)"/.exec( + fs.readFileSync(pnpmWorkspacePath, "utf8") + ); + assert(match !== null, `Expected ${match[1]} to be <major>.<minor>.<patch>`); + return match[1]; +} +/** + * Gets the correct version to bump `miniflare` to, ensuring the minor versions + * of `workerd` and `miniflare` match. Minor bumps in changesets will become + * patch bumps if the `workerd` version hasn't changed. + * See `changeset-version.test.js` for examples. + * + * @param workerdVersion `workerd` version constraint in `miniflare` package + * @param previousVersion `miniflare` version before running `changeset version` + * @param version `miniflare` version after running `changeset version` + */ +function getNextMiniflareVersion(workerdVersion, previousVersion, version) { + const [, workerdMinor] = parseVersion(workerdVersion); + const [, , previousPatch] = parseVersion(previousVersion); + const [major, minor] = parseVersion(version); + if (workerdMinor === minor) { + // If the minor versions match already, there's nothing we need to do + return version; + } else if (workerdMinor > minor) { + // If the workerd minor is greater than the miniflare minor, + // use the workerd minor and reset patch to 0 + return `${major}.${workerdMinor}.0`; + } else { + // Otherwise, if the workerd minor is less than the miniflare minor, + // use the workerd minor and bump the patch instead + return `${major}.${workerdMinor}.${previousPatch + 1}`; + } +} +exports.getNextMiniflareVersion = getNextMiniflareVersion; + +function main() { + // 1. Get `miniflare` version before applying changesets, so we know if the + // minor version was bumped + const previousMiniflarePkg = getPkg(miniflarePkgPath); + const previousMiniflareVersion = previousMiniflarePkg.version; + + // 2. Run standard `changeset version` command to apply changesets, bump + // versions, and update changelogs + console.log("Applying changesets and updating versions..."); + execSync("pnpm exec changeset version", { stdio: "inherit" }); + + // 3. Force `miniflare`'s minor version to be the same as `workerd` + console.log("Getting miniflare and workerd versions..."); + const miniflarePkg = getPkg(miniflarePkgPath); + const miniflareVersion = miniflarePkg.version; + const workerdVersion = getWorkerdVersion(); + const nextMiniflareVersion = getNextMiniflareVersion( + workerdVersion, + previousMiniflareVersion, + miniflareVersion + ); + if (nextMiniflareVersion !== miniflareVersion) { + // If `changeset version` didn't produce the correct version on its own... + + // ...update `miniflare`'s `package.json` version + console.log(`Updating miniflare version to ${nextMiniflareVersion}...`); + miniflarePkg.version = nextMiniflareVersion; + setPkg(miniflarePkgPath, miniflarePkg); + + const changedPathsBuffer = execSync("git ls-files --modified", { + cwd: rootPath, + }); + console.log("Checking modified files..."); + const changedPaths = changedPathsBuffer.toString().trim().split("\n"); + for (const relativeChangedPath of changedPaths) { + const changedPath = path.resolve(rootPath, relativeChangedPath); + const name = path.basename(changedPath); + if (name === "package.json") { + console.log("Updating dependencies in", changedPath); + // ...update `miniflare` version in dependencies of other packages + const pkg = getPkg(changedPath); + let changed = false; + for (const key of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) { + const constraint = pkg[key]?.["miniflare"]; + if (constraint === undefined) continue; + // Don't update `workspace:`-style constraints + if (constraint.startsWith("workspace:")) continue; + pkg[key]["miniflare"] = nextMiniflareVersion; + changed = true; + } + if (changed) setPkg(changedPath, pkg); + } else if (name === "CHANGELOG.md") { + console.log("Updating changelog in", changedPath); + // ...update `CHANGELOG.md`s with correct version + let changelog = fs.readFileSync(changedPath, "utf8"); + // Replace version header in `miniflare` `CHANGELOG.md` + changelog = changelog.replace( + `## ${miniflareVersion}`, + `## ${nextMiniflareVersion}` + ); + // Replace `Updated dependencies` line in other `CHANGELOG.md`s + changelog = changelog.replace( + `- miniflare@${miniflareVersion}`, + `- miniflare@${nextMiniflareVersion}` + ); + fs.writeFileSync(changedPath, changelog); + } + } + } + + // 4. Update the lockfile + console.log("Updating lockfile..."); + execSync("pnpm install --lockfile-only", { stdio: "inherit" }); + console.log("Done."); +} + +if (require.main === module) main(); diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4e5ecc6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +version: 2 +updates: + # Automatically check for updates in framework CLIs for C3 + - package-ecosystem: "npm" + open-pull-requests-limit: 10 + directory: "/packages/create-cloudflare/src/frameworks" + schedule: + interval: "weekly" + # Don't run these at midday on Mondays, when we are busy trying to land PRs + day: "sunday" + time: "06:00" + versioning-strategy: increase + # Match the 24h minimumReleaseAge enforced by pnpm in pnpm-workspace.yaml + # so Dependabot doesn't open PRs that CI will then reject. + cooldown: + default-days: 1 + # the following is used to add the [C3] prefix to the PR title, + # we override the commit message but setting a prefix like this + # makes it so that also the PR title gets such prefix + commit-message: + prefix: "[C3] " + labels: + - "package:c3" + - "dependencies" + - "ci:skip-pr-description-validation" + + # Check for workerd & workers-types updates for Miniflare + - package-ecosystem: "npm" + # If you restrict the update to a directory that is not the root + # then it will not update the pnpm-lock.yaml. + directory: "/" + groups: + # We want to keep workerd and workers-types updates in lock-step + workerd-and-workers-types: + patterns: + - "workerd" + - "@cloudflare/workers-types" + schedule: + interval: "daily" + time: "06:00" + versioning-strategy: increase + labels: + - "miniflare" + - "dependencies" + - "ci:skip-pr-description-validation" + allow: + - dependency-name: "workerd" + - dependency-name: "@cloudflare/workers-types" diff --git a/.github/extract-pr-and-workflow-id.js b/.github/extract-pr-and-workflow-id.js new file mode 100644 index 0000000..55810e7 --- /dev/null +++ b/.github/extract-pr-and-workflow-id.js @@ -0,0 +1,21 @@ +// This file is not used directly. +// Instead its contents are used in `.github/workflows/write-prerelease-comment.yml` +// Any changes here should be copied into the CI step there. +const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, +}); + +for (const artifact of allArtifacts.data.artifacts) { + // Extract the PR number from the artifact name + const match = /^npm-package-(.+)-(\d+)$/.exec(artifact.name); + if (match) { + const packageName = match[1].toUpperCase(); + require("fs").appendFileSync( + process.env.GITHUB_ENV, + `\nWORKFLOW_RUN_PR_FOR_${packageName}=${match[2]}` + + `\nWORKFLOW_RUN_ID_FOR_${packageName}=${context.payload.workflow_run.id}` + ); + } +} diff --git a/.github/get-c3-dependabot-bumped-framework.cjs b/.github/get-c3-dependabot-bumped-framework.cjs new file mode 100644 index 0000000..8bdde7f --- /dev/null +++ b/.github/get-c3-dependabot-bumped-framework.cjs @@ -0,0 +1,26 @@ +const { execSync } = require("child_process"); + +module.exports = function () { + const diff = execSync( + `git diff origin/${process.env.GITHUB_BASE_REF} -- packages/create-cloudflare/src/frameworks/package.json` + ).toString(); + + const changedPackages = diff + .match(/-\s*".*?":\s".*?",?/g) + .map((match) => match.match(/-\s*"(.*)":/)?.[1]) + .filter(Boolean); + + if (changedPackages.length === 0) { + console.warn("No changes detected!"); + return null; + } else if (changedPackages.length > 1) { + console.warn( + `More then one package has changed (${changedPackages.join( + ", " + )}), that's not currently supported` + ); + throw new Error("More than one package bump detected"); + } else { + return changedPackages[0]; + } +}; diff --git a/.github/holopin.yml b/.github/holopin.yml new file mode 100644 index 0000000..6c269f6 --- /dev/null +++ b/.github/holopin.yml @@ -0,0 +1,9 @@ +organization: cloudflare +stickers: + - id: cltg4w6r612040fl2r7vweuvv + alias: "ci:outstanding-contribution" + +holobytes: + - evolvingStickerId: cltg4rlqc39020fl51scnguhb + alias: contribution + in: true diff --git a/.github/opencode.json b/.github/opencode.json new file mode 100644 index 0000000..9b765bb --- /dev/null +++ b/.github/opencode.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://opencode.ai/config.json", + "disabled_providers": ["opencode"], + "enabled_providers": ["cloudflare-ai-gateway"], + "provider": { + "cloudflare-ai-gateway": { + "models": { + "anthropic/claude-opus-4-8": {}, + "anthropic/claude-sonnet-4-5": {}, + "workers-ai/@cf/moonshotai/kimi-k2.6": {} + } + } + }, + "permission": { + "read": "allow", + "edit": "allow", + "glob": "allow", + "grep": "allow", + "bash": "deny", + "task": "allow", + "skill": "allow", + "todoread": "allow", + "todowrite": "allow", + "webfetch": "deny" + } +} diff --git a/.github/prereleases/upload.mjs b/.github/prereleases/upload.mjs new file mode 100644 index 0000000..1648821 --- /dev/null +++ b/.github/prereleases/upload.mjs @@ -0,0 +1,50 @@ +import { execSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, "../.."); + +function getPackagePaths() { + const stdout = execSync( + 'pnpm list --filter="./packages/*" --recursive --depth=-1 --parseable', + { cwd: projectRoot, encoding: "utf8" } + ); + return stdout.split("\n").filter((pkgPath) => path.isAbsolute(pkgPath)); +} + +function getPackage(pkgPath) { + const json = fs.readFileSync(path.join(pkgPath, "package.json"), "utf8"); + return { + path: pkgPath, + json: JSON.parse(json), + }; +} + +function getPackages() { + return getPackagePaths().map(getPackage); +} + +export function getPackagesForPrerelease() { + return getPackages().filter((pkg) => pkg.json["workers-sdk"]?.prerelease); +} + +const pkgs = getPackagesForPrerelease(); + +spawnSync( + "pnpm", + [ + "dlx", + "pkg-pr-new", + "publish", + "--pnpm", + "--compact", + "--no-template", + ...pkgs.map((pkg) => pkg.path), + ], + { + stdio: "inherit", + } +); diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c4d9e9d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +Fixes #[insert GH or internal issue link(s)]. + +_Describe your change..._ + +--- + +<!-- +Please don't delete the checkboxes <3 +The following selections do not need to be completed if this PR only contains changes to .md files +--> + +- Tests + - [ ] Tests included/updated + - [ ] Automated tests not possible - manual testing has been completed as follows: + - [ ] Additional testing not necessary because: +- Public documentation + - [ ] Cloudflare docs PR(s): <!--e.g. <https://github.com/cloudflare/cloudflare-docs/pull/>...--> + - [ ] Documentation not necessary because: + +*A picture of a cute animal (not mandatory, but encouraged)* + +<!-- +Have you read our [Contributing guide](https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md)? +In particular, for non-trivial changes, please always engage on the issue or create a discussion or feature request issue first before writing your code. +--> diff --git a/.github/skills/issue-review.md b/.github/skills/issue-review.md new file mode 100644 index 0000000..4424519 --- /dev/null +++ b/.github/skills/issue-review.md @@ -0,0 +1,376 @@ +--- +name: github-issue-review +description: Triage a GitHub issue on cloudflare/workers-sdk. Assesses quality, identifies the component, checks for duplicates, and recommends next steps. +--- + +# GitHub Issue Triage Skill + +This skill triages a GitHub issue to assess its quality, identify the affected component, and recommend next steps for the maintainers. + +**Important:** This skill runs without bash or network access. All analysis is based on the pre-fetched issue data in `context.json`. Use only `read`, `glob`, `grep`, `edit`, and `write` tools. + +## Input + +Pre-fetched issue data at `data/<issue_number>/context.json` containing the full issue details from the GitHub API (title, body, comments, labels, author, dates). + +## Triage Process + +Perform the following steps in order. **Stop and skip to Output as soon as you have sufficient evidence for a recommendation.** + +### Step 1: Load Issue Details + +Read the pre-fetched `context.json` file using the `read` tool. + +Extract and note: + +- Title and description +- Issue type (bug report vs feature request vs question) +- Product/component affected +- Version reported against (if any) +- Operating system (if any) +- Reproduction steps or reproduction link (if any) +- Error messages (if any) +- Comment count and any relevant comment content +- Labels currently applied +- Issue age (created date) and last activity date +- `state_reason` if the issue is already closed + +### Step 2: Check for Closeable Issues + +Check each of the following categories in order. **STOP at the first match** and skip to Output. + +> **Note:** The `state_reason` values in this section (`completed`, `not_planned`) are informational — they indicate the appropriate GitHub close reason for maintainer reference. They are not captured as a field in the output report. + +#### 2a: Spam or Junk + +Recommend **CLOSE** (state_reason: `not_planned`, no comment needed) if: + +- The body contains random characters, test text, or nonsensical content (e.g. "bhhg", "ssssssssss", "aloll") +- The issue is clearly spam (e.g. loan advertisements, SEO spam, unrelated product promotions) +- The issue is a blank/empty test issue (e.g. title is "test" with no meaningful body) +- The body is entirely empty or contains only template headers with no content filled in + +#### 2b: Reporter Confirmed Resolved + +Recommend **CLOSE** (state_reason: `completed`) if: + +- The reporter explicitly confirmed the issue is resolved in comments +- The reporter said they found a workaround and no longer need a fix +- A maintainer indicated it should be closed in comments + +#### 2c: Already Fixed in a Prior Release + +Recommend **CLOSE** (state_reason: `completed`) if, based on references within the issue comments or linked PRs: + +- A comment references a PR or release that addresses this issue +- The issue describes a bug that was fixed in a version newer than the reporter's version +- The issue's symptoms match a fix described in a linked PR or comment + +When closing, cite the specific PR number and release version if known. + +**Template:** + +> This issue has been fixed in PR #XXXX, which was released in **wrangler X.Y.Z** (or the relevant package version). Please update and let us know if you're still seeing the issue. + +Or for older issues where the fix can't be pinpointed: + +> This is a fairly old issue, and from testing with the latest version, it appears to have been resolved. I'm going to close it for now — if you're still running into problems on the latest version, feel free to open a new issue with more details and we can investigate further. + +#### 2d: Duplicate + +Recommend **CLOSE** (state_reason: `not_planned`) if a comment or linked reference in the issue identifies it as a duplicate of another issue — for example, a maintainer has already commented pointing to a canonical issue, or the reporter themselves links to an existing report. + +When closing, link to the canonical issue. + +**Template:** + +> Closing as a duplicate of #XXXX. Please follow that issue for updates. + +If the duplicate is in another repo: + +> I'm closing this as a duplicate of <owner/repo>#XXXX, which is where this is being tracked. + +#### 2e: Not workers-sdk / Wrong Repo + +Recommend **CLOSE** (state_reason: `not_planned`) if: + +- The issue describes Workers **runtime** behavior (fetch API quirks, V8 issues, compatibility flags, crypto APIs) — belongs in [cloudflare/workerd](https://github.com/cloudflare/workerd) +- The issue is about a third-party framework (Nuxt, SvelteKit, Remix, Hono, React Router) and the bug is in that framework's code, not in Wrangler or the Vite plugin +- The issue is about an upstream tool (esbuild, Bun, Vite core) rather than Cloudflare's integration +- The issue is an account/billing/abuse problem — belongs in Cloudflare Support + +**Templates:** + +For framework issues: + +> It looks like this issue is coming from <framework>, rather than from workers-sdk. I'd recommend opening an issue on that project's repo: <link>. If it turns out to be a workers-sdk issue after all, feel free to open a new issue here with more details. + +For runtime issues: + +> This looks like a Workers runtime issue rather than a tooling issue. The right place to track this is [cloudflare/workerd](https://github.com/cloudflare/workerd) — could a maintainer transfer this issue there? + +(Set Action field to: "Transfer to cloudflare/workerd, then post this comment.") + +For account/support issues: + +> Unfortunately, we're unable to provide support for account-level issues via GitHub. Please contact Cloudflare Support at https://dash.cloudflare.com/?to=/:account/support or the email address mentioned in the error message. + +#### 2f: Stale / No Response + +Recommend **CLOSE** (state_reason: `not_planned`) if: + +- The issue has the `awaiting reporter response` or `needs reproduction` label AND the last activity was >30 days ago +- The issue is >12 months old with no activity in the last 6 months +- A maintainer asked for a reproduction or clarification and the reporter never responded + +**Template:** + +> We haven't heard from you in a while so I'm going to close this issue for now. If you're still running into problems, feel free to open a new issue with more details and we can investigate further. + +For very old issues: + +> This is a very old issue so I'm going to close it for now. If you're still running into problems on the latest version, feel free to comment with more details and we can investigate further. + +#### 2g: Transient Platform Issue + +Recommend **CLOSE** (state_reason: `completed`) if: + +- The issue describes a one-time API error (500, 503, auth failures) that appears to have been a service incident +- Multiple users reported the same error around the same time, and it has since stopped +- The error is from Cloudflare's API (not from Wrangler itself) and there's no way to fix it in workers-sdk + +**Template:** + +> This was a transient API issue, and should be resolved now. If you're still seeing this error, please open a new issue with the latest details and we can investigate further. + +Or for old transient issues: + +> This appears to have been a transient server-side API issue. Similar reports were caused by Cloudflare API incidents that have since been resolved. I'm going to close this for now. + +#### 2h: User Error / Misunderstanding + +Recommend **CLOSE** (state_reason: `not_planned`) if: + +- The issue describes expected behavior that the reporter misunderstands +- The issue is caused by incorrect configuration, wrong SQL syntax, or misuse of an API +- The fix is a documentation pointer rather than a code change + +When closing, explain the correct approach and link to relevant documentation. Be helpful, not dismissive. + +**Template:** + +> This isn't a bug — <brief explanation of the correct behavior>. You can find more details in the docs: <link>. I'm going to close this for now, but feel free to ask on our [Discord](https://discord.cloudflare.com) if you have more questions. + +#### 2i: Breaking Change + +Recommend **KEEP OPEN** if: + +- The requested change would be a breaking change in the current major version + +The issue should remain open as a tracking item. Do not close it — the team hasn't decided against the change, it's deferred to a future major version. + +**Suggested labels:** `breaking change` + +**Template:** + +> Changing this behavior at this stage would be a breaking change, so it will need to wait for the next major version. I've added the `breaking change` label to track this. + +(Set Action field to: "Apply `breaking change` label, then post this comment.") + +#### 2j: Won't Fix / By Design + +Recommend **CLOSE** (state_reason: `not_planned`) if: + +- The behavior is intentional and documented +- The team has explicitly decided not to implement this feature +- The cost/complexity of the change outweighs the benefit + +**Templates:** + +For design decisions: + +> This is intentional behavior — <brief explanation>. I don't think we're likely to change this in the near future, but we appreciate the feedback. + +For feature requests that won't happen: + +> Thanks for the suggestion. We don't intend to implement this right now, but it may be revisited in future. <Optional: brief explanation of why, or pointer to an alternative approach.> + +#### 2k: Feature Superseded / Deprecated + +Recommend **CLOSE** (state_reason: `completed`) if: + +- The requested feature has been implemented via a different mechanism than proposed +- The issue relates to a deprecated feature that has a replacement +- A newer feature or package addresses the underlying need + +**Template:** + +> This has been addressed by <feature/package>. <Brief explanation of how it solves the original request.> I'm going to close this for now. + +### Step 3: Check for Insufficient Information + +If the issue wasn't caught by Step 2, recommend **NEEDS MORE INFO** if: + +- Bug report has no reproduction steps or link (the bug template requires one) +- Bug report is missing version information +- The description is too vague to act on (e.g. "it doesn't work" with no details) +- The error message is missing or truncated + +**Template:** + +> Thanks for reporting this. Could you provide <specific missing information>? In particular: +> +> - <specific question 1> +> - <specific question 2> +> +> A minimal reproduction (a GitHub repo or link we can clone and run) would also help us investigate. Without more details, we won't be able to look into this. + +**Suggested label:** `awaiting reporter response` (and optionally `needs reproduction`) + +**STOP HERE if** the issue clearly needs more info. Skip to Output. + +### Step 4: Identify Component + +Map the issue to a package based on labels, title, and body content: + +| Signal | Package | +| ------------------------------------------------------------------------ | ----------------------------------------------- | +| `wrangler` label, wrangler CLI commands, `wrangler.toml`/`wrangler.json` | `packages/wrangler` | +| `miniflare` label, local dev simulation | `packages/miniflare` | +| `d1` label, D1 database, `d1 execute`, migrations | `packages/wrangler` (D1 code is in wrangler) | +| `vitest` label, worker tests, `vitest-pool-workers` | `packages/vitest-pool-workers` | +| `vite-plugin` label, vite dev, `@cloudflare/vite-plugin` | `packages/vite-plugin-cloudflare` | +| `c3` label, `create-cloudflare`, project scaffolding | `packages/create-cloudflare` | +| `pages` label, Pages deployment, `_routes.json`, `_headers` | `packages/wrangler` (Pages code is in wrangler) | +| `Workers + Assets` label, static asset serving | `packages/wrangler` | +| `containers` label, container registry | `packages/wrangler` | +| `workflows` label, Workflows API | `packages/wrangler` | +| `workers-builds` label | Workers Builds (may be internal) | +| `python` label, Python Workers | `packages/wrangler` | +| `workers for platforms` label, dispatch namespaces | `packages/wrangler` | +| `kv-asset-handler` label | `packages/kv-asset-handler` | +| `types` label, `wrangler types` command | `packages/wrangler` | +| R2, KV, Queues, Durable Objects, Vectorize bindings | `packages/wrangler` | +| `node compat`/`nodejs compat` label, Node.js APIs | May be workerd or wrangler depending on context | +| Workers runtime behavior (not tooling) | Likely belongs in cloudflare/workerd | +| Cloudflare dashboard, API behavior | Likely a platform issue, not workers-sdk | + +### Step 5: Assess Reproducibility and Severity + +For **bug reports**, evaluate: + +- **Has reproduction?** Does the issue include a minimal repro link or clear steps? +- **Severity estimate:** Is this a crash, data loss, incorrect behavior, or cosmetic issue? +- **Scope:** Does it affect all users or a specific configuration? +- **Workaround available?** Did the reporter or comments mention one? +- **Version gap:** Is the reporter on an old version? Could updating fix this? + +For **feature requests**, evaluate: + +- **Clarity:** Is the proposed solution clearly described? +- **Use case:** Is the motivation explained? +- **Scope:** Small enhancement vs large new feature? +- **Existing alternatives:** Is there already a way to achieve this (even if less convenient)? + +## Output Format + +**How the output is used:** `report.md` is posted verbatim as a maintainer-facing triage comment on the issue (attributed to an automated bot), and the labels in `summary.json` are applied automatically. Because of this: + +- Write `report.md` for maintainers, not the reporter. The **Suggested Comment** section is a _draft_ for a maintainer to send to the reporter — it is **not** posted automatically, so do not phrase the surrounding report as though a reply has already been made. +- Only put labels in the **Suggested Labels** fields that already exist in the repository. If unsure whether a label exists, omit it rather than guess — non-existent labels are dropped before applying, so inventing labels only weakens the report's accuracy. + +### Report Directory Structure + +``` +./data/<issue_number>/ +├── report.md # Full detailed report +└── summary.json # Structured JSON summary +``` + +### Output Step 1: Write Full Report + +Write the full report to `./data/<issue_number>/report.md`: + +```markdown +# Issue Triage: <owner/repo>#<number> + +## Summary + +<One-line summary of the issue> + +## Classification + +- **Type:** <Bug | Feature Request | Question | Discussion> +- **Component:** <package name or "unknown"> +- **Severity:** <Critical | High | Medium | Low | N/A> +- **Has Reproduction:** <Yes (with link) | No | N/A> +- **Quality:** <Complete | Partial | Incomplete> + +## Findings + +- **Created:** <date> +- **Author:** <username> +- **Version:** <reported version, or "not specified"> +- **Labels:** <labels, or "none"> +- **Comments:** <count> + +### Analysis + +<2-5 bullet points covering what you found — component identification, +reproducibility assessment, severity reasoning, staleness indicators, +duplicate candidates. Only include what's relevant.> + +## Recommendation + +**Status:** <CLOSE | KEEP OPEN | NEEDS MORE INFO | NEEDS VERIFICATION> + +**Reasoning:** <2-3 sentences explaining why> + +**Action:** <What a maintainer should do next> + +**Suggested Labels:** <labels to add, if any. Use existing repo labels only.> + +### Suggested Comment + +> <The exact comment to post on the issue. Follow the templates above for the +> matching closure category. Key principles: +> +> - Always offer an escape hatch ("feel free to open a new issue") +> - Link to specific PRs, releases, or docs when available +> - Be concise for straightforward closures, detailed for design decisions +> - Never be dismissive or snarky, even for spam (just close silently) or user error +> - For NEEDS MORE INFO, ask specific questions (not generic "please provide more details") +> Omit this section entirely for spam (close without comment) or if no comment is needed.> +``` + +### Output Step 2: Write Summary File + +Write a machine-readable summary as **JSON** to `./data/<issue_number>/summary.json`. This file is parsed by the triage workflow (to build the dashboard payload and apply labels), so it must be valid JSON with exactly these keys: + +```json +{ + "issueNumber": <issue_number as a number>, + "title": "<issue title, with emoji/template prefixes like 'Bug:' removed>", + "githubUrl": "https://github.com/<owner>/<repo>/issues/<issue_number>", + "recommendation": "<CLOSE | KEEP OPEN | NEEDS MORE INFO | NEEDS VERIFICATION>", + "difficulty": "<easy | medium | hard | n/a>", + "reasoning": "<brief reasoning, 1-2 sentences>", + "suggestedAction": "<brief description of next steps>", + "hasSuggestedComment": <true if a Suggested Comment section is present in the report, false otherwise>, + "suggestedLabels": ["<label>", "..."] +} +``` + +**Key definitions:** + +- **issueNumber**: The issue number as a JSON number (not a string). +- **title**: Issue title with any emoji prefixes (e.g. "🐛 Bug:") or template prefixes removed. +- **githubUrl**: Full URL to the issue. +- **recommendation**: One of `CLOSE`, `KEEP OPEN`, `NEEDS MORE INFO`, `NEEDS VERIFICATION`. +- **difficulty**: Estimated fix difficulty — `easy`, `medium`, `hard`, or `n/a` (for feature requests or closures). +- **reasoning**: Brief summary of why (1-2 sentences). +- **suggestedAction**: Brief description of next steps. +- **hasSuggestedComment**: Boolean — `true` if the report includes a Suggested Comment, `false` otherwise. +- **suggestedLabels**: JSON array of labels to apply, matching the **Suggested Labels** field in the report. Use existing repo labels only (see above). Use an empty array `[]` if there are no labels to apply. + +**CRITICAL:** Write valid, parseable JSON only (no trailing commas, no comments, no surrounding markdown fences). Because JSON strings are escaped, `reasoning`/`suggestedAction` may safely contain any punctuation. diff --git a/.github/version-script.js b/.github/version-script.js new file mode 100644 index 0000000..fb13807 --- /dev/null +++ b/.github/version-script.js @@ -0,0 +1,35 @@ +/** + * Update the package.json version property for the given package + * + * Usage: + * + * ``` + * node ./.github/version-script.js <package-name> + * ``` + * + * `<package-name>` defaults to `wrangler` if not provided. + */ + +const { readFileSync, writeFileSync } = require("fs"); +const { execSync } = require("child_process"); + +try { + const packageName = getArgs()[0] ?? "wrangler"; + const packageJsonPath = `./packages/${packageName}/package.json`; + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const stdout = execSync("git rev-parse --short HEAD", { encoding: "utf8" }); + pkg.version = "0.0.0-" + stdout.trim(); + writeFileSync(packageJsonPath, JSON.stringify(pkg, null, "\t") + "\n"); +} catch (error) { + console.error(error); + process.exit(1); +} + +/** + * Get the command line args, stripping `node` and script filename, etc. + */ +function getArgs() { + const args = Array.from(process.argv); + while (args.shift() !== module.filename) {} + return args; +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..ee5eaff --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,151 @@ +# GitHub Actions + +See below for a summary of this repo's Actions + +- _Actions marked with "⚠️" are expected to sometimes fail._ + +## Security auditing + +We use [`zizmor`](https://docs.zizmor.sh/) to audit GitHub Actions workflow definitions and keep CI workflows as safe as possible. When changing files in this directory, run: + +```sh +zizmor .github/workflows/*.yml +``` + +Workflow changes should avoid unsuppressed `zizmor` findings. In particular: + +- Pin external actions to immutable commit SHAs, not tags. +- Use `actions/checkout` v6 or newer so persisted credentials are stored under `$RUNNER_TEMP`; set `persist-credentials: false` when a job does not need follow-up authenticated Git operations. +- Pass GitHub expression values into shell steps through `env` instead of expanding `${{ ... }}` directly inside `run` blocks. +- Treat privileged triggers such as `pull_request_target` and `workflow_run` as security-sensitive. If a privileged trigger is required, document the safety model and add a targeted `zizmor` ignore with a reason. + +## PR related actions + +### Tests + Checks (test-and-check.yml) + +- Triggers + - Updates to PRs. + - PRs in the merge queue. +- Actions + - Builds all the packages. + - Runs formatting, linting and type checks. + - Runs fixture tests, Wrangler unit tests, C3 unit tests, Miniflare unit tests, and ESLint + Prettier checks. + - Adds the PR to a GitHub project + - Makes sure that Wrangler's warning for old Node.js versions works. + +### Wrangler E2E tests (e2e-wrangler.yml) + +- Triggers + - Updates to PRs on the Cloudflare fork. + - PRs in the merge queue. +- Actions + - Runs the E2E tests for Wrangler. + - Cloudflare API credentials are only passed on Version Packages PRs (`changeset-release/main`), in the merge queue, or when the `ci:run-remote-tests` label is applied. Other PRs run the E2E suite without remote tests. + +### Vite Plugin E2E tests (e2e-vite.yml) + +- Triggers + - Updates to PRs on the Cloudflare fork. + - PRs in the merge queue. +- Actions + - Runs the E2E tests for the Vite plugin. + - Cloudflare API credentials are only passed on Version Packages PRs (`changeset-release/main`), in the merge queue, or when the `ci:run-remote-tests` label is applied. Other PRs run the E2E suite without remote tests. + +## Deploy Pages Previews (deploy-pages-preview.yml) + +- Triggers + - Updates to PRs that have one of the `preview:...` labels. +- Actions + - Deploy a preview of the matching Pages project to Cloudflare. + +## Deploy (to testing) and Test Playground Preview Worker (worker-playground-preview-testing-env-deploy-and-test.yml) + +- Triggers + - Commits merged to the `main` branch, on the Cloudflare fork, which touch files in the `packages/playground-preview-worker` directory. + - Updates to PRs, on the Cloudflare fork, with the `playground-worker` label applied. +- Actions + - Runs integrations tests to ensure the behaviour of the Worker powering the Workers Playground. + +## Create Pull Request Prerelease (prerelease.yml) + +- Triggers + - Updates to PRs. +- Actions + - Creates an installable pre-release of any package containing `{ "workers-sdk": { "prerelease": true } }` in its `package.json` (e.g. Wrangler, C3, and Miniflare) on every PR. + - Adds a comment to the PR with links to the pre-releases. + +## Housekeeping actions + +### Add issues to DevProd project (issues.yml) + +- Triggers + - Updates to issues. +- Actions + - Add the issue to a GitHub project. + +### Triage Issue (triage-issue.yml) + +- Triggers + - A new issue is opened (skips PRs and bot-authored issues). + - A new comment is created on an issue — re-triages with the latest context. Skips PRs, bot comments, and the triage bot's own sticky comment (matched by the sticky-comment marker) to avoid re-triage loops. + - Manual `workflow_dispatch` with an `issue-number` input. +- Actions + - Runs an OpenCode agent with the `.github/skills/issue-review.md` skill against the pre-fetched issue data (including existing comments) to produce a markdown triage report (`report.md`) and a structured JSON summary (`summary.json`). + - Uploads the report to the triage dashboard. + - Posts the report and structured summary as a maintainer-facing sticky comment on the issue and applies the suggested labels (validated against existing repo labels). Because the comment is sticky, re-triage updates the existing comment rather than posting a duplicate. Comments and labels are attributed to the workers-devprod bot via `GH_ACCESS_TOKEN`. + - The AI agent itself runs sandboxed (no shell or network access); all GitHub writes happen in workflow steps from the generated files. + +### Generate changesets for dependabot PRs (c3-dependabot-versioning-prs.yml and miniflare-dependabot-versioning-prs.yml) + +- Triggers + - Updates to PRs, by the dependabot user, which update one of: + - frameworks dependencies in C3, + - miniflare. +- Actions + - Generates changesets for the affected package. + +### E2E Project Cleanup (e2e-project-cleanup.yml) + +- Triggers + - Scheduled to run at 3am each day. +- Actions + - Deletes any Workers and Pages projects that were not properly cleaned up by the E2E tests. + +## Main branch actions + +### Handle Changesets (changesets.yml) + +- Triggers + - Commits merged to the `main` branch, on the Cloudflare fork. +- Actions + - If there are changeset in the working directory, create or update a "Version Packages" PR to prep for a release. + - If there are no changesets, release any packages that have a bump to their version in this change. + - Public packages are deployed to npm + - Private packages will run their `deploy` script, if they have one. + +## C3 related actions + +### C3 E2E Tests (c3-e2e.yml) + +- Triggers + - Updates to PRs. +- Actions + - Runs the E2E tests for C3. + - Cloudflare API credentials are only passed on Version Packages PRs (`changeset-release/main`), in the merge queue, or when the `ci:run-remote-tests` label is applied. Other PRs run the E2E suite without remote tests. + +### Rerun Code Owners (rerun-codeowners.yml + rerun-codeowners-privileged.yml) + +- Triggers + - A review is submitted or dismissed on a PR. +- Actions + - Re-runs the "Run Codeowners Plus" check so it re-evaluates approval status after the review change. + - Uses the `workflow_run` pattern: the trigger workflow exists solely to fire a `workflow_run` event; the privileged companion workflow (which has full permissions) reads the PR head SHA from `github.event.workflow_run.head_sha` and performs the re-run. This is necessary because `pull_request_review` gives a read-only token for fork PRs and has no `_target` variant. + +### Rerun Remote Tests (rerun-remote-tests.yml) + +- Triggers + - The `ci:run-remote-tests` or `run-c3-frameworks-tests` label is added to or removed from a PR. +- Actions + - Re-runs the E2E workflows for the PR so they pick up the label change and pass (or withhold) API credentials to the test steps. + - `ci:run-remote-tests` re-runs Wrangler, Vite, and C3 E2E workflows; `run-c3-frameworks-tests` re-runs only C3 E2E. + - Uses `pull_request_target` to get a privileged token even for fork PRs (safe because no untrusted code is checked out). diff --git a/.github/workflows/bonk-pr-review.yml b/.github/workflows/bonk-pr-review.yml new file mode 100644 index 0000000..a19e00b --- /dev/null +++ b/.github/workflows/bonk-pr-review.yml @@ -0,0 +1,69 @@ +name: New PR Review + +on: + pull_request: + types: [opened] + +jobs: + review: + # Skip Version Packages PRs (auto-generated by the changesets action) since they don't need a Bonk review + if: | + github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name && + !(github.event.pull_request.base.repo.owner.login == 'cloudflare' && github.event.pull_request.head.ref == 'changeset-release/main') + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + permissions: + id-token: write + contents: read + issues: write + pull-requests: write + steps: + - name: Check if PR author is Cloudflare org member + run: | + STATUS=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/orgs/cloudflare/members/${PR_AUTHOR}" \ + --silent -i 2>/dev/null | head -1 | awk '{print $2}') || true + if [ "$STATUS" != "204" ]; then + echo "User ${PR_AUTHOR} is not a member of the Cloudflare organization" + exit 1 + fi + echo "User ${PR_AUTHOR} is a Cloudflare org member" + env: + GH_TOKEN: ${{ secrets.READ_ONLY_ORG_GITHUB_TOKEN }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 30 + persist-credentials: false + + - name: Load review prompt + id: prompt + run: | + { + echo 'value<<EOF' + echo "You are reviewing PR #${{ github.event.pull_request.number }} on ${{ github.repository }}." + echo "" + cat .github/bonk_reviewer.md + echo EOF + } >> "$GITHUB_OUTPUT" + + - name: Run Bonk + uses: ask-bonk/ask-bonk/github@24832587005550860c8ad13fd96519e731ca632a # main + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }} + CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }} + with: + model: "cloudflare-ai-gateway/anthropic/claude-opus-4-8" + variant: "high" + forks: "false" + permissions: write + opencode_version: 1.15.13 # pin to this version as certain ones cause ProviderInitError issues + prompt: ${{ steps.prompt.outputs.value }} diff --git a/.github/workflows/bonk.yml b/.github/workflows/bonk.yml new file mode 100644 index 0000000..e26078f --- /dev/null +++ b/.github/workflows/bonk.yml @@ -0,0 +1,80 @@ +name: Bonk + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: false + +jobs: + bonk: + if: github.event.sender.type != 'Bot' && (contains(github.event.comment.body, '/bonk') || contains(github.event.comment.body, '@ask-bonk')) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + id-token: write + contents: write + issues: write + pull-requests: write + steps: + - name: Check if comment author is Cloudflare org member + run: | + STATUS=$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/orgs/cloudflare/members/${COMMENT_AUTHOR}" \ + --silent -i 2>/dev/null | head -1 | awk '{print $2}') || true + if [ "$STATUS" != "204" ]; then + echo "User ${COMMENT_AUTHOR} is not a member of the Cloudflare organization" + exit 1 + fi + echo "User ${COMMENT_AUTHOR} is a Cloudflare org member" + env: + GH_TOKEN: ${{ secrets.READ_ONLY_ORG_GITHUB_TOKEN }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Build prompt with triggering comment + id: prompt + run: | + { + DELIMITER=$(openssl rand -hex 16) + echo "value<<$DELIMITER" + echo "You were invoked by @${COMMENT_AUTHOR} on ${COMMENT_URL}" + echo "" + echo "Their comment:" + echo '```' + echo "$COMMENT_BODY" + echo '```' + echo "" + echo "This is your task. Read it carefully and act on it." + echo "$DELIMITER" + } >> "$GITHUB_OUTPUT" + env: + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_URL: ${{ github.event.comment.html_url }} + + - name: Run Bonk + uses: ask-bonk/ask-bonk/github@c39e982defd0114385df54e72012a3fc4333c4d4 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }} + CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }} + with: + model: "cloudflare-ai-gateway/anthropic/claude-opus-4-8" + variant: "high" + mentions: "/bonk,@ask-bonk" + permissions: write + agent: bonk + opencode_version: 1.15.13 # pin to this version as certain ones cause ProviderInitError issues + prompt: ${{ steps.prompt.outputs.value }} diff --git a/.github/workflows/c3-dependabot-versioning-prs.yml b/.github/workflows/c3-dependabot-versioning-prs.yml new file mode 100644 index 0000000..9e1964a --- /dev/null +++ b/.github/workflows/c3-dependabot-versioning-prs.yml @@ -0,0 +1,39 @@ +name: "C3 - Generate changesets for dependabot PRs" +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] dependabot-only job requires write access to push generated changesets; checkout v6 persists credentials under RUNNER_TEMP + paths: + - "packages/create-cloudflare/src/frameworks/package.json" + +permissions: + # content:write permission needed to update add changesets to dependabot PRs + # (see tools/dependabot/generate-dependabot-pr-changesets.ts) + contents: write + +jobs: + generate-changeset: + runs-on: ubuntu-slim + if: | + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 2 + persist-credentials: true + ref: ${{ github.head_ref }} + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Configure Git + run: | + git config --global user.email wrangler@cloudflare.com + git config --global user.name 'Wrangler automated PR updater' + + - name: Generate C3 changesets + # Keep the final param (the changeset prefix: `c3-frameworks-update`) in sync with the filter in the `.github/workflows/c3-e2e-dependabot.yml` workflow. + run: node -r esbuild-register tools/dependabot/generate-dependabot-pr-changesets.ts "$PR_NUMBER" create-cloudflare packages/create-cloudflare/src/frameworks/package.json c3-frameworks-update + env: + PR_NUMBER: ${{ github.event.number }} diff --git a/.github/workflows/c3-e2e.yml b/.github/workflows/c3-e2e.yml new file mode 100644 index 0000000..9dab2e4 --- /dev/null +++ b/.github/workflows/c3-e2e.yml @@ -0,0 +1,311 @@ +name: C3 E2E +on: + merge_group: + pull_request: + +permissions: + contents: read + pull-requests: read + +# pnpm 11-specific settings - recognised by pnpm 11+ and +# silently ignored by pnpm 10. +# +# - pmOnFail=ignore: pnpm 11's default `download` would silently re-exec as +# the monorepo's pinned pnpm 10.33.0, defeating the matrix override. +# - verifyDepsBeforeRun=false: pnpm 11's default `install` would re-run +# `pnpm install` at the monorepo root before invoking the script, which +# then trips on engines.pnpm. +env: + pnpm_config_pm_on_fail: ignore + pnpm_config_verify_deps_before_run: "false" + +jobs: + e2e: + # Runs the non-frameworks C3 E2E tests on all supported operating systems + # and package managers. + timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os.name }}-${{ matrix.pm.name }}-${{ matrix.pm.version }}${{ matrix.experimental && '-experimental' || '' }}-${{ matrix.filter }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + # `matrix.pm.label` is the display name in job/artifact names. Default + # pm entries use the plain name (e.g. `pnpm`) to keep historical required- + # check names stable; non-default entries (e.g. pnpm 11) suffix the version. + name: ${{ format('C3 E2E ({0}, {1}{2}) - {3}', matrix.pm.label, matrix.os.description, matrix.experimental && ', experimental' || '', matrix.filter) }} + strategy: + fail-fast: false + matrix: + experimental: [false] + filter: ["cli", "workers"] + os: [{ name: ubuntu-latest, description: Linux }] + pm: + - { name: pnpm, version: "10.33.0", label: "pnpm" } + - { name: pnpm, version: "11.5.1", label: "pnpm@11.5.1" } + - { name: npm, version: "0.0.0", label: "npm" } + # The yarn tests keep failing on Linux with out of space errors, with no clear reason why. Disabling for now. + # - { name: yarn, version: "1.0.0", label: "yarn" } + include: + # Windows and experimental entries stay on the default pnpm to + # preserve historical required-check names. pnpm 11 coverage comes + # from the Linux base entries above. + - os: { name: windows-latest, description: Windows } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } + filter: "cli" + - os: { name: windows-latest, description: Windows } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } + filter: "workers" + - os: { name: ubuntu-latest, description: Linux } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } + experimental: true + filter: "cli" + - os: { name: ubuntu-latest, description: Linux } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } + experimental: true + filter: "workers" + runs-on: ${{ matrix.os.name }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Install Python uv + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-python-uv + + # Needed because gatsby requires git + - name: Configure Git + shell: bash + run: | + git config --global user.email wrangler@cloudflare.com + git config --global user.name 'Wrangler Tester' + + # Put the matrix pnpm version on PATH so scaffolded-project installs + # run it (not the monorepo's pinned pnpm 10.33.0). Without this, the + # matrix only relabels C3's `npm_config_user_agent` and pnpm 11 + # regressions go undetected. + # + # `pnpm/action-setup` refuses when both `version` input and the root + # `package.json#packageManager` are set, so we point it at a stub + # `package.json`. We also loosen `engines.pnpm` to avoid pnpm 11 + # bailing with ERR_PNPM_UNSUPPORTED_ENGINE at the monorepo root. + - name: Prepare workspace for matrix pnpm install + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + mkdir -p .ci-pnpm-setup-stub + printf '{"name":"ci-stub","private":true}\n' > .ci-pnpm-setup-stub/package.json + node -e " + const fs = require('node:fs'); + const pkg = JSON.parse(fs.readFileSync('package.json','utf8')); + if (pkg.engines && pkg.engines.pnpm) pkg.engines.pnpm = '*'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + - name: Install matrix pnpm version for scaffolded installs + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ matrix.pm.version }} + package_json_file: .ci-pnpm-setup-stub/package.json + # Distinct dest so `addPath` prepends ahead of the monorepo's + # pinned pnpm 10.x from the earlier `~/setup-pnpm`. + dest: ~/matrix-pnpm + - name: Verify pnpm on PATH matches matrix version + if: matrix.pm.name == 'pnpm' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + got="$(pnpm --version)" + want="${{ matrix.pm.version }}" + echo "pnpm on PATH: $got (expected $want)" + [ "$got" = "$want" ] || { echo "ERROR: PATH pnpm is $got, not $want — scaffolded installs would silently run the wrong version."; exit 1; } + + - id: run-e2e + if: steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: pnpm run test:e2e:c3 + env: + NODE_VERSION: ${{ env.NODE_VERSION }} + E2E_EXPERIMENTAL: ${{ matrix.experimental }} + E2E_TEST_PM: ${{ matrix.pm.name }} + E2E_TEST_PM_VERSION: ${{ matrix.pm.version }} + E2E_TEST_FILTER: ${{ matrix.filter }} + CI_OS: ${{ runner.os }} + GITHUB_TOKEN: ${{ github.token }} # Needed for begit to clone the repo in the e2e tests for solid-start + + - name: Upload Logs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() && steps.changes.outputs.everything_but_markdown == 'true' }} + with: + name: ${{ format('e2e-logs-{0}-{1}-{2}-{3}', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} + path: packages/create-cloudflare/.e2e-logs${{matrix.experimental == 'true' && '-experimental' || ''}}/${{ matrix.pm.name }}/${{ matrix.filter }} + include-hidden-files: true + + - name: Upload Turbo Summary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() && steps.changes.outputs.everything_but_markdown == 'true' }} + with: + name: ${{ format('turbo-runs-{0}-{1}-{2}-{3}', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal', matrix.filter) }} + path: .turbo/runs + include-hidden-files: true + + frameworks-e2e: + # Runs the C3 frameworks E2E tests only in the merge queue, on the release branch, if create-cloudflare has changed, or when explicitly requested via label. + # + # Frameworks that delegate install to their own generator (e.g. `hono`) + # don't route through C3's `ERR_PNPM_IGNORED_BUILDS` recovery path; those + # tests opt out of pnpm 11 via `unsupportedPmRanges` in the test config. + timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-frameworks${{ matrix.experimental && '-experimental' || '' }}-${{ github.ref }}-${{ matrix.os.name }}-${{ matrix.pm.name }}-${{ matrix.pm.version }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + name: ${{ format('C3 E2E ({0}, {1}{2}) - frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && ', experimental' || '') }} + strategy: + fail-fast: false + matrix: + experimental: [false] + os: + - { name: ubuntu-latest, description: Linux } + pm: + - { name: pnpm, version: "10.33.0", label: "pnpm" } + - { name: pnpm, version: "11.5.1", label: "pnpm@11.5.1" } + - { name: npm, version: "0.0.0", label: "npm" } + include: + # Experimental stays on the default pnpm to preserve historical + # required-check names; pnpm 11 coverage comes from the base entry. + - os: { name: ubuntu-latest, description: Linux } + pm: { name: pnpm, version: "10.33.0", label: "pnpm" } + experimental: true + runs-on: ${{ matrix.os.name }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + framework_deps: + - 'packages/create-cloudflare/**' + + - name: Check if frameworks tests should run + id: check-frameworks + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + FRAMEWORK_DEPS_CHANGED: ${{ steps.changes.outputs.framework_deps }} + run: | + if [ "$EVENT_NAME" = "merge_group" ] || \ + [ "$HEAD_REF" = "changeset-release/main" ] || \ + [ "$FRAMEWORK_DEPS_CHANGED" = "true" ]; then + echo "run_frameworks=true" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "pull_request" ]; then + HAS_LABEL=$(gh pr view "$PR_NUMBER" \ + --repo "$REPO" --json labels \ + --jq '[.labels[].name] | any(. == "run-c3-frameworks-tests")') + echo "run_frameworks=${HAS_LABEL}" >> "$GITHUB_OUTPUT" + else + echo "run_frameworks=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install Dependencies + if: steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Install Python uv + if: steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-python-uv + + - name: Configure Git + if: steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + git config --global user.email wrangler@cloudflare.com + git config --global user.name 'Wrangler Tester' + + # See the equivalent step in the `e2e` job above for context. + - name: Prepare workspace for matrix pnpm install + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + mkdir -p .ci-pnpm-setup-stub + printf '{"name":"ci-stub","private":true}\n' > .ci-pnpm-setup-stub/package.json + node -e " + const fs = require('node:fs'); + const pkg = JSON.parse(fs.readFileSync('package.json','utf8')); + if (pkg.engines && pkg.engines.pnpm) pkg.engines.pnpm = '*'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + - name: Install matrix pnpm version for scaffolded installs + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ matrix.pm.version }} + package_json_file: .ci-pnpm-setup-stub/package.json + dest: ~/matrix-pnpm + - name: Verify pnpm on PATH matches matrix version + if: matrix.pm.name == 'pnpm' && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: | + got="$(pnpm --version)" + want="${{ matrix.pm.version }}" + echo "pnpm on PATH: $got (expected $want)" + [ "$got" = "$want" ] || { echo "ERROR: PATH pnpm is $got, not $want — scaffolded installs would silently run the wrong version."; exit 1; } + + - id: run-e2e + if: steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' + shell: bash + run: pnpm run test:e2e:c3 + env: + NODE_VERSION: ${{ env.NODE_VERSION }} + E2E_EXPERIMENTAL: ${{ matrix.experimental }} + E2E_TEST_PM: ${{ matrix.pm.name }} + E2E_TEST_PM_VERSION: ${{ matrix.pm.version }} + E2E_TEST_FILTER: frameworks + CI_OS: ${{ runner.os }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload Logs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' }} + with: + name: ${{ format('e2e-logs-{0}-{1}-{2}-frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} + path: packages/create-cloudflare/.e2e-logs${{matrix.experimental == 'true' && '-experimental' || ''}}/${{ matrix.pm.name }}/frameworks + include-hidden-files: true + + - name: Upload Turbo Summary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() && steps.check-frameworks.outputs.run_frameworks == 'true' && steps.changes.outputs.everything_but_markdown == 'true' }} + with: + name: ${{ format('turbo-runs-{0}-{1}-{2}-frameworks', matrix.pm.label, matrix.os.description, matrix.experimental && 'experimental' || 'normal') }} + path: .turbo/runs + include-hidden-files: true diff --git a/.github/workflows/changeset-review.yml b/.github/workflows/changeset-review.yml new file mode 100644 index 0000000..ac53963 --- /dev/null +++ b/.github/workflows/changeset-review.yml @@ -0,0 +1,92 @@ +name: Changeset Review + +on: + pull_request: + paths: + - ".changeset/*.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + +permissions: + contents: read + pull-requests: write + issues: write + id-token: write + +jobs: + review-changesets: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout changesets + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + sparse-checkout: | + .changeset + .github/opencode.json + + - name: Install OpenCode + # pin OpenCode to 1.4.6 version as newer versions are causing ProviderInitError issues + run: | + npm install -g opencode-ai@1.4.6 + cp .github/opencode.json ./opencode.json + + - name: Get changed changeset files + id: changed-changesets + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + .changeset/*.md + files_ignore: | + .changeset/README.md + # Recover deleted files so the AI can read them (needed for Version Packages PRs) + recover_deleted_files: ${{ github.event.pull_request.title == 'Version Packages' }} + + - name: Review Changesets with OpenCode + id: opencode-review + # Run for Version Packages PRs (which delete changesets) or regular PRs with new changesets + if: github.event.pull_request.title == 'Version Packages' || steps.changed-changesets.outputs.added_files_count > 0 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }} + CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }} + DELETED_FILES: ${{ steps.changed-changesets.outputs.deleted_files }} + ADDED_FILES: ${{ steps.changed-changesets.outputs.added_files }} + run: | + opencode --model "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6" run --print-logs \ + "Review the changeset files in this PR. + + For \"Version Packages\" PRs, review: ${DELETED_FILES} + For regular PRs, review: ${ADDED_FILES} + + Read \`.changeset/README.md\` for guidelines, then validate: + 1. **Version Type**: Accept the author's choice of patch/minor unless clearly incorrect + 2. **Changelog Quality**: Meaningful descriptions (examples encouraged but not required for features) + 3. **Markdown Headers**: No h1/h2/h3 headers (breaks changelog formatting) + 4. **Analytics**: If the change collects more analytics, it should be a minor even though there is no user-visible change + 5. **Dependabot**: Do not validate dependency update changesets for create-cloudflare + 6. **Experimental features**: Changesets for experimental features should include note on how users can opt in. + + If all changesets pass, just output \"✅ All changesets look good\" - no need for a detailed checklist. + + Do not review other files, only the changesets. This is specifically a changeset review action. + + If there are issues, output \"⚠️ Issues found\" followed by the specific problems. + + Write your review to changeset-review.md." + + - name: Post review comment + if: steps.opencode-review.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 + with: + header: changeset-review + path: changeset-review.md + + - name: Skip notice + if: github.event.pull_request.title != 'Version Packages' && steps.changed-changesets.outputs.added_files_count == 0 + run: | + echo "No new changesets to review (only minor edits to pre-existing changesets detected)" diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml new file mode 100644 index 0000000..92b4dde --- /dev/null +++ b/.github/workflows/changesets.yml @@ -0,0 +1,108 @@ +name: Handle Changesets + +on: + push: + branches: + - main + +permissions: + id-token: write + contents: read + # note: no write permissions are needed since the workflow uses GH_ACCESS_TOKEN instead of GITHUB_TOKEN + +env: + # This workflow only builds and publishes — it never runs Playwright tests. + # Skip the browser download to avoid the postinstall script hanging in CI. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + +jobs: + release: + if: ${{ github.repository_owner == 'cloudflare' }} + name: Handle Changesets + runs-on: macos-latest-large + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # See https://github.com/changesets/action/issues/187 + token: ${{ secrets.GH_ACCESS_TOKEN }} + persist-credentials: true + # Pull in more history to cover the changeset commits + fetch-depth: 500 + + - name: Install Dependencies + # Defense in depth: do not pass Turbo remote cache credentials and + # disable the pnpm store cache so release builds always resolve + # packages from the registry and rebuild every task from source, + # rather than restoring from a (potentially poisoned) cache. + uses: ./.github/actions/install-dependencies + with: + # Pinned to 24.18.0 (rather than floating `24`) until it's safe to + # float back: Node 24.17.0 regressed node-fetch@2 keep-alive responses + # with ERR_STREAM_PREMATURE_CLOSE (nodejs/node#63989, fixed in 24.18.0). + node-version: 24.18.0 + disable-cache: "true" + + - name: Check npm version + run: node -r esbuild-register tools/deployments/check-npm-version.ts + + - name: Check the changesets + run: node -r esbuild-register tools/deployments/validate-changesets.ts + + - name: Build all packages + run: pnpm run build + env: + CI_OS: ${{ runner.os }} + SOURCEMAPS: "false" + ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} + ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }} + SENTRY_DSN: "https://9edbb8417b284aa2bbead9b4c318918b@sentry10.cfdata.org/583" + NODE_ENV: "production" + # This is the "production" key for sparrow analytics. + # Include this here because this step will rebuild Wrangler and needs to have this available + SPARROW_SOURCE_KEY: "50598e014ed44c739ec8074fdc16057c" + + - name: Create Version PR or Publish to NPM + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + version: node .github/changeset-version.js + publish: pnpm exec changeset publish + env: + GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + + # Switch to Node 22 for non-npm package deployments because quick-edit + # builds native modules (tree-sitter) that are not compatible with + # Node 24's C++20 requirement for V8 headers. + - name: Switch to Node 22 for non-npm package deployments + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + # Pinned to 22.23.1 (rather than floating `22`) until it's safe to + # float back: Node 22.23.0 regressed node-fetch@2 keep-alive responses + # with ERR_STREAM_PREMATURE_CLOSE (nodejs/node#63989, fixed in 22.23.1). + node-version: 22.23.1 + + - name: Deploy non-NPM Packages + id: deploy + run: | + node -r esbuild-register tools/deployments/deploy-non-npm-packages.ts + echo "status=$(cat deployment-status.json)" >> $GITHUB_OUTPUT; + env: + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + WORKERS_NEW_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.WORKERS_NEW_CLOUDFLARE_ACCOUNT_ID }} + WORKERS_NEW_CLOUDFLARE_API_TOKEN: ${{ secrets.WORKERS_NEW_CLOUDFLARE_API_TOKEN }} + WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN: ${{ secrets.WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN }} + WORKERS_SHARED_SENTRY_ACCESS_ID: ${{ secrets.WORKERS_SHARED_SENTRY_ACCESS_ID }} + WORKERS_SHARED_SENTRY_ACCESS_SECRET: ${{ secrets.WORKERS_SHARED_SENTRY_ACCESS_SECRET }} + WORKERS_SHARED_SENTRY_AUTH_TOKEN: ${{ secrets.WORKERS_SHARED_SENTRY_AUTH_TOKEN }} + + - name: Send Alert + if: always() + run: node -r esbuild-register tools/deployments/alert-on-error.ts + env: + PUBLISH_STATUS: ${{ steps.changesets.outcome }} + HAS_CHANGESETS: ${{ steps.changesets.outputs.hasChangesets }} + DEPLOYMENT_STATUS: ${{ steps.deploy.outputs.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TOKEN: ${{ secrets.STATUS_BOT_SECRET }} diff --git a/.github/workflows/codeowners.yml b/.github/workflows/codeowners.yml new file mode 100644 index 0000000..23faa90 --- /dev/null +++ b/.github/workflows/codeowners.yml @@ -0,0 +1,54 @@ +name: "Code Owners" + +# Re-evaluate when PRs are opened/updated. +# When reviews are submitted/dismissed, the separate rerun-codeowners.yml workflow +# re-runs this check (rather than creating a second check context). +# Using pull_request_target (not pull_request) so the workflow has access to secrets +# for fork PRs. This is safe because: +# - The checkout is the BASE branch (ownership rules come from the protected branch) +# - PR head commits are fetched as git objects only (never checked out or executed) +# - The action only reads config files and calls the GitHub API +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] checks base branch ownership rules and fetches PR head for diff computation without executing PR code + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + +concurrency: + group: codeowners-${{ github.event.pull_request.number }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + codeowners: + name: "Run Codeowners Plus" + runs-on: ubuntu-latest + # Each step is skipped when: + # - the PR head is the changeset-release branch (auto-generated changeset PRs), or + # - the PR base is not `main` (PRs against feature branches don't need formal review). + # Note: if we ever introduce a maintenance branch (e.g. a long-lived release branch), + # it would need to be added to the base branch allowlist below. + steps: + - name: "Checkout Base Branch" + if: github.event.pull_request.head.ref != 'changeset-release/main' && github.event.pull_request.base.ref == 'main' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: "Fetch PR Head (for diff computation)" + if: github.event.pull_request.head.ref != 'changeset-release/main' && github.event.pull_request.base.ref == 'main' + run: git fetch origin +refs/pull/${{ github.event.pull_request.number }}/head + env: + GITHUB_TOKEN: "${{ secrets.CODEOWNERS_GITHUB_PAT }}" + + - name: "Codeowners Plus" + if: github.event.pull_request.head.ref != 'changeset-release/main' && github.event.pull_request.base.ref == 'main' + uses: multimediallc/codeowners-plus@ff02aa993a92e8efe01642916d0877beb9439e9f # v1.9.0 + with: + github-token: "${{ secrets.CODEOWNERS_GITHUB_PAT }}" + pr: "${{ github.event.pull_request.number }}" + verbose: true + quiet: ${{ github.event.pull_request.draft }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..775142b --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,141 @@ +name: "Dependabot - auto-merge workerd updates" + +# workerd ships a release every weekday, so the workerd-and-workers-types +# Dependabot group produces a steady stream of mechanical PRs that bump +# `workerd`, `@cloudflare/workers-types`, and miniflare's pinned version in +# lockstep (see .github/dependabot.yml). When CI is green these PRs require +# no human review, so we enable GitHub auto-merge on them — required status +# checks remain the gate, and a failing build still parks the PR for a human. +# +# Security model: this workflow effectively bypasses the human-review +# requirement on PRs whose head branch matches the Dependabot naming pattern, +# so we have to be paranoid about exactly what we're auto-merging. Before +# enabling auto-merge we verify that the PR contains exactly the two commits +# we expect (one from Dependabot, one from `miniflare-dependabot-versioning-prs.yml`) +# and that nothing outside the expected fileset has been touched. If any +# subsequent push violates those invariants we actively *disable* auto-merge, +# so a maintainer pushing a follow-up commit cancels rather than rides the +# auto-merge. +# +# DO NOT add `actions/checkout` to this workflow — `pull_request_target` +# grants write-scoped tokens, and checking out PR-controlled code with +# those tokens is the standard pwn vector. + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] intentionally privileged metadata-only workflow; it never checks out or executes PR code + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + enable-auto-merge: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-slim + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 # v2.3.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify PR matches expected workerd-bump shape + id: verify + if: steps.meta.outputs.dependency-group == 'workerd-and-workers-types' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # Pull commits and changed files via the GitHub API. `--repo` is + # required because this workflow runs without `actions/checkout`, + # so `gh` has no git remote to infer the repo from. + commits_json=$(gh pr view --repo "$REPO" "$PR_NUMBER" --json commits) + files_json=$(gh pr view --repo "$REPO" "$PR_NUMBER" --json files) + + fail() { + echo "verified=false" >> "$GITHUB_OUTPUT" + echo "reason=$1" >> "$GITHUB_OUTPUT" + echo "::warning::Refusing to enable auto-merge: $1" + exit 0 + } + + # --- Commit shape --------------------------------------------------- + # Expected: exactly two commits. + # 1. dependabot[bot] author, with a verified GitHub signature. + # 2. The changeset commit pushed by miniflare-dependabot-versioning-prs.yml, + # authored as `Wrangler automated PR updater <wrangler@cloudflare.com>`. + # + # We can't require a signature on the second commit (it's pushed via + # GH_ACCESS_TOKEN, which doesn't sign), so we lean on path/content + # checks below to constrain what that commit can do. + + commit_count=$(echo "$commits_json" | jq '.commits | length') + if [ "$commit_count" -ne 2 ]; then + fail "expected exactly 2 commits, found $commit_count" + fi + + first_author=$(echo "$commits_json" | jq -r '.commits[0].authors[0].login') + first_oid=$(echo "$commits_json" | jq -r '.commits[0].oid') + if [ "$first_author" != "dependabot[bot]" ]; then + fail "first commit author is '$first_author', expected 'dependabot[bot]'" + fi + + # `gh pr view --json commits` doesn't expose signature info, so look + # it up via the REST commit endpoint. + first_verified=$(gh api "repos/$REPO/commits/$first_oid" --jq '.commit.verification.verified') + if [ "$first_verified" != "true" ]; then + fail "first commit (Dependabot) does not have a verified signature" + fi + + second_email=$(echo "$commits_json" | jq -r '.commits[1].authors[0].email // ""') + second_message=$(echo "$commits_json" | jq -r '.commits[1].messageHeadline // ""') + if [ "$second_email" != "wrangler@cloudflare.com" ]; then + fail "second commit author email is '$second_email', expected 'wrangler@cloudflare.com'" + fi + if ! echo "$second_message" | grep -qE '^Update dependencies of '; then + fail "second commit message '$second_message' does not match expected changeset commit shape" + fi + + # --- Changed files allowlist --------------------------------------- + # The only paths a workerd bump should touch: + # - .changeset/dependabot-update-*.md (added by the changeset job) + # - packages/*/package.json (workerd, workers-types pins) + # - pnpm-lock.yaml + # - pnpm-workspace.yaml (catalog entries) + allowed='^(\.changeset/dependabot-update-.*\.md|packages/[^/]+/package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$' + + unexpected=$(echo "$files_json" | jq -r '.files[].path' | grep -vE "$allowed" || true) + if [ -n "$unexpected" ]; then + fail "PR touches unexpected files:$(echo "$unexpected" | sed 's/^/ /' | tr '\n' ',')" + fi + + # Make sure the changeset is actually present — the changeset job + # may not have run yet, in which case we bail and wait for the + # `synchronize` event from its push. + if ! echo "$files_json" | jq -e '.files[] | select(.path | startswith(".changeset/dependabot-update-"))' > /dev/null; then + fail "changeset file not yet present; waiting for changeset job to push it" + fi + + echo "verified=true" >> "$GITHUB_OUTPUT" + + - name: Enable auto-merge + if: steps.meta.outputs.dependency-group == 'workerd-and-workers-types' && steps.verify.outputs.verified == 'true' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Disable auto-merge if verification failed + # If a previous run enabled auto-merge but a later push broke the + # invariants, actively cancel auto-merge so the bad commit can't ride. + # `gh pr merge --disable-auto` is a no-op (and exits 0) if auto-merge + # was never enabled, so this is safe to always run on the failure path. + if: always() && steps.meta.outputs.dependency-group == 'workerd-and-workers-types' && steps.verify.outputs.verified != 'true' + run: gh pr merge --disable-auto "$PR_URL" || true + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-previews.yml b/.github/workflows/deploy-previews.yml new file mode 100644 index 0000000..e33d379 --- /dev/null +++ b/.github/workflows/deploy-previews.yml @@ -0,0 +1,90 @@ +name: Deploy Previews + +# This workflow is designed to deploy a "preview" version of a project based on PR labels. + +# Triggers: +# - update to a PR that has one of the `preview:...` labels +# +# Actions: +# - deploy the matching project to Cloudflare. +# +# PR Label | Project +# --------------------------------------------------------- +# preview:chrome-devtools-patches | packages/chrome-devtools-patches +# preview:quick-edit | packages/quick-edit +# +# Note: this workflow does not run tests against these packages, only deploys previews. + +on: + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled] + +permissions: + contents: read + # pull-request:write permission needed so that the workflow can comment on PRs + pull-requests: write + +jobs: + deploy-projects: + # Only run this on PRs that are for the "cloudflare" org and not "from" `main` + # - non-Cloudflare PRs will not have the secrets needed + # - PRs "from" main would accidentally do a production deployment + if: github.repository_owner == 'cloudflare' && github.head_ref != 'main' && (contains(github.event.*.labels.*.name, 'preview:chrome-devtools-patches') || contains(github.event.*.labels.*.name, 'preview:quick-edit')) + timeout-minutes: 60 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-app-previews + runs-on: macos-latest-large + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Build tools and libraries + run: pnpm run build + env: + NODE_ENV: "production" + CI_OS: ${{ runner.os }} + + - name: Deploy Wrangler DevTools preview + if: contains(github.event.*.labels.*.name, 'preview:chrome-devtools-patches') + run: | + output=$(pnpm --filter @cloudflare/chrome-devtools-patches run deploy:preview) + echo "Command output: $output" + echo "Extracting deployed URL from command output" + url=$(echo "$output" | sed -nE "s/.*Version Preview URL: ([^[:space:]]+).*/\1/p") + echo "Extracted URL: $url" + echo "VITE_DEVTOOLS_PREVIEW_URL=$url" >> $GITHUB_ENV + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Build and Deploy Quick Edit preview + if: contains(github.event.*.labels.*.name, 'preview:quick-edit') + run: pnpm --filter quick-edit run preview + env: + DEBIAN_FRONTEND: noninteractive + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + NODE_OPTIONS: "--max_old_space_size=30000" + + - name: "Comment on PR with Devtools Link" + if: contains(github.event.*.labels.*.name, 'preview:chrome-devtools-patches') + uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 + with: + header: chrome-devtools-preview + message: | + The Wrangler DevTools preview is now live. You can access it directly at: ${{ env.VITE_DEVTOOLS_PREVIEW_URL }}/js_app + + In order to test the DevTools preview in `wrangler`: + + 1. `npx wrangler dev`. + 2. Hit `d` to open the DevTools in a fresh browser window. + 3. Paste the DevTools preview URL into the address bar (keeping all existing query parameters), e.g: + + ``` + - https://devtools.devprod.cloudflare.dev/js_app?theme=systemPreferred&ws=127.0.0.1%3A9229%2Fws&domain=tester&debugger=true + + ${{ env.VITE_DEVTOOLS_PREVIEW_URL }}/js_app?theme=systemPreferred&ws=127.0.0.1%3A9229%2Fws&domain=tester&debugger=true + ``` diff --git a/.github/workflows/e2e-local-explorer-ui.yml b/.github/workflows/e2e-local-explorer-ui.yml new file mode 100644 index 0000000..bb5657c --- /dev/null +++ b/.github/workflows/e2e-local-explorer-ui.yml @@ -0,0 +1,69 @@ +name: Local Explorer UI E2E + +on: + merge_group: + pull_request: + +permissions: + contents: read + +env: + # We run `playwright install` manually instead + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + +jobs: + test: + name: Local Explorer UI E2E + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + relevant: + - 'packages/local-explorer-ui/**' + - 'packages/miniflare/src/workers/local-explorer/**' + - 'fixtures/worker-with-resources/**' + - 'fixtures/shared/**' + + - name: Install Dependencies + if: steps.changes.outputs.relevant == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + + - name: Install Playwright + if: steps.changes.outputs.relevant == 'true' + run: pnpm --filter @cloudflare/local-explorer-ui playwright:install + + - name: Build wrangler + if: steps.changes.outputs.relevant == 'true' + run: pnpm turbo build --filter wrangler + + - name: Run Local Explorer UI E2E tests + if: steps.changes.outputs.relevant == 'true' + run: pnpm test:e2e -F @cloudflare/local-explorer-ui --log-order=stream + timeout-minutes: 30 + env: + CI_OS: Linux + NODE_OPTIONS: "--max_old_space_size=8192" + + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs + path: .turbo/runs diff --git a/.github/workflows/e2e-project-cleanup.yml b/.github/workflows/e2e-project-cleanup.yml new file mode 100644 index 0000000..e5d6306 --- /dev/null +++ b/.github/workflows/e2e-project-cleanup.yml @@ -0,0 +1,33 @@ +# This workflow cleans up any leftover projects created by e2e runs. + +name: E2E Project Cleanup + +on: + workflow_dispatch: + schedule: + - cron: "0 */2 * * *" # Run every 2 hours + +permissions: + contents: read + +jobs: + cleanup: + timeout-minutes: 30 + name: "Cleanup Test Projects" + if: ${{ github.repository_owner == 'cloudflare' }} + runs-on: ubuntu-slim + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Cleanup E2E test projects + run: node -r esbuild-register tools/e2e/e2eCleanup.ts + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/e2e-vite.yml b/.github/workflows/e2e-vite.yml new file mode 100644 index 0000000..90fcbf7 --- /dev/null +++ b/.github/workflows/e2e-vite.yml @@ -0,0 +1,73 @@ +name: Vite Plugin E2E + +on: + merge_group: + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + e2e-vite-plugin-test: + name: ${{ format('Vite Plugin E2E ({0})', matrix.description) }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + description: macOS + - os: windows-latest + description: Windows + - os: ubuntu-latest + description: Linux + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Check if remote tests should run + id: check-remote + uses: ./.github/actions/check-remote-tests + + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Run Vite plugin E2E tests + if: steps.changes.outputs.everything_but_markdown == 'true' + run: pnpm test:e2e -F @cloudflare/vite-plugin --log-order=stream + timeout-minutes: 20 + env: + NODE_DEBUG: "vite-plugin:test" + # The remote-binding tests need to connect to Cloudflare + CLOUDFLARE_API_TOKEN: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_API_TOKEN || '' }} + CLOUDFLARE_ACCOUNT_ID: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_ACCOUNT_ID || '' }} + NODE_OPTIONS: "--max_old_space_size=8192" + CI_OS: ${{ matrix.os }} + + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs-${{ matrix.os }} + path: .turbo/runs diff --git a/.github/workflows/e2e-wrangler.yml b/.github/workflows/e2e-wrangler.yml new file mode 100644 index 0000000..8a1a88a --- /dev/null +++ b/.github/workflows/e2e-wrangler.yml @@ -0,0 +1,88 @@ +name: Wrangler E2E + +on: + merge_group: + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + e2e-wrangler-test: + name: ${{ format('Wrangler E2E ({0}, shard {1}/4)', matrix.description, matrix.shard) }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.shard }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + shard: [1, 2, 3, 4] + include: + - os: macos-latest + description: macOS + - os: windows-latest + description: Windows + - os: ubuntu-latest + description: Linux + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Check if remote tests should run + id: check-remote + uses: ./.github/actions/check-remote-tests + + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Run Wrangler E2E tests + if: steps.changes.outputs.everything_but_markdown == 'true' + run: pnpm run test:e2e:wrangler + env: + CLOUDFLARE_API_TOKEN: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_API_TOKEN || '' }} + CLOUDFLARE_ACCOUNT_ID: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_ACCOUNT_ID || '' }} + HYPERDRIVE_DATABASE_URL: ${{ secrets.TEST_HYPERDRIVE_DATABASE_URL}} + HYPERDRIVE_MYSQL_DATABASE_URL: ${{ secrets.TEST_HYPERDRIVE_MYSQL_DATABASE_URL}} + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + CI_OS: ${{ matrix.os }} + E2E_SHARD: ${{ matrix.shard }} + E2E_SHARD_COUNT: "4" + + - name: Run getPlatformProxy() remote-bindings e2e tests + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.shard == 1 + run: pnpm run test:e2e -F @fixture/get-platform-proxy-remote-bindings + env: + TEST_CLOUDFLARE_API_TOKEN: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_API_TOKEN || '' }} + TEST_CLOUDFLARE_ACCOUNT_ID: ${{ steps.check-remote.outputs.run-remote == 'true' && secrets.TEST_CLOUDFLARE_ACCOUNT_ID || '' }} + NODE_OPTIONS: "--max_old_space_size=8192" + CI_OS: ${{ matrix.os }} + + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs-${{ matrix.os }}-shard-${{ matrix.shard }} + path: .turbo/runs diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml new file mode 100644 index 0000000..c80c651 --- /dev/null +++ b/.github/workflows/hotfix-release.yml @@ -0,0 +1,82 @@ +name: Release a hotfix + +on: + workflow_dispatch: + inputs: + pr-number: + description: "Which PR should be released?" + required: true + confirm: + description: "Confirm that the PR is up to date with the last published release, with the specific additional changes that should be released as part of this hotfix and the relevant package patch versions bumped" + type: boolean + required: true + label: + description: "Which dist-tag should this release have?" + type: string + default: hotfix + required: true + +permissions: + contents: read + id-token: write + +jobs: + hotfix-release: + name: Hotfix Release + if: ${{ inputs.confirm == true }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check user for team affiliation + uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v2 + id: teamAffiliation + with: + GITHUB_TOKEN: ${{ secrets.READ_ONLY_ORG_GITHUB_TOKEN }} + username: ${{ github.actor }} + team: wrangler + + - name: Stop workflow if user is not a wrangler team member + if: ${{ steps.teamAffiliation.outputs.isTeamMember == false }} + run: | + echo "You must be on the "wrangler" team to trigger this job." + exit 1 + + - name: "Checkout PR" + run: | + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::PR number must be numeric" + exit 1 + fi + gh pr checkout "$PR_NUMBER" + env: + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + PR_NUMBER: ${{ inputs.pr-number }} + + - name: Install Dependencies + # Defense in depth: do not pass Turbo remote cache credentials and + # disable the pnpm store cache so release builds always resolve + # packages from the registry and rebuild every task from source, + # rather than restoring from a (potentially poisoned) cache. + uses: ./.github/actions/install-dependencies + with: + disable-cache: "true" + + - name: Build all packages + run: pnpm run build + env: + CI_OS: ${{ runner.os }} + + - name: Publish packages + run: | + if ! [[ "$NPM_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::npm dist-tag contains unsupported characters" + exit 1 + fi + pnpm publish -r --tag "$NPM_TAG" --filter wrangler --filter miniflare --filter create-cloudflare + env: + NPM_TAG: ${{ inputs.label }} diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..1bd64a2 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,41 @@ +name: Add issues to DevProd project + +on: + issues: + types: [opened, transferred] + +permissions: + # issues:write permission needed so that the workflow can manage issues + issues: write + +jobs: + add-to-project: + name: Add issue to project + runs-on: ubuntu-slim + steps: + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + with: + project-url: https://github.com/orgs/cloudflare/projects/1 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + labeled: bug, enhancement + label-operator: OR + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + with: + project-url: https://github.com/orgs/cloudflare/projects/2 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + labeled: product:pages + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + with: + project-url: https://github.com/orgs/cloudflare/projects/6 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + labeled: product:d1 + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + with: + project-url: https://github.com/orgs/cloudflare/projects/12 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + labeled: product:c3 + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + with: + project-url: https://github.com/orgs/cloudflare/projects/8 + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + labeled: product:queues diff --git a/.github/workflows/miniflare-dependabot-versioning-prs.yml b/.github/workflows/miniflare-dependabot-versioning-prs.yml new file mode 100644 index 0000000..b6e6e38 --- /dev/null +++ b/.github/workflows/miniflare-dependabot-versioning-prs.yml @@ -0,0 +1,51 @@ +name: "Miniflare - Generate changesets for dependabot PRs" + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] dependabot-only job requires write access to push generated changesets; checkout v6 persists credentials under RUNNER_TEMP + paths: + - "packages/miniflare/package.json" + - "pnpm-workspace.yaml" + +permissions: + # content:write permission needed to update add changesets to dependabot PRs + # (see tools/dependabot/generate-dependabot-pr-changesets.ts) + contents: write + +jobs: + generate-changeset: + runs-on: ubuntu-slim + if: | + github.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 2 + persist-credentials: true + ref: ${{ github.head_ref }} + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Configure Git + run: | + git config --global user.email wrangler@cloudflare.com + git config --global user.name 'Wrangler automated PR updater' + + - name: Generate Miniflare changesets + # The paremeters to the script are: + # - PR: The number of the current Dependabot PR + # - Packages: Coma-separated names of the workers-sdk packages whose dependencies are being updated + # - PackageJSON: The path to the package JSON being updated by Dependabot + # - Changeset prefix: The prefix to go on the front of the filename of the generated changeset + run: >- + node -r esbuild-register tools/dependabot/generate-dependabot-pr-changesets.ts + "$PR_NUMBER" + miniflare,wrangler + packages/miniflare/package.json + dependabot-update + env: + PR_NUMBER: ${{ github.event.number }} diff --git a/.github/workflows/opencode-issue-solver.yml b/.github/workflows/opencode-issue-solver.yml new file mode 100644 index 0000000..8bae748 --- /dev/null +++ b/.github/workflows/opencode-issue-solver.yml @@ -0,0 +1,19 @@ +name: OpenCode Issue Solver + +on: + workflow_dispatch: + inputs: + issue-number: + description: "Issue number to attempt to fix" + required: true + type: number + +jobs: + assess-issue: + runs-on: ubuntu-latest + permissions: {} + + steps: + - name: Exit + run: | + exit 1 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..1a02cc1 --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,53 @@ +name: Prerelease + +on: + pull_request: + push: + branches: + - main + - d1 + - workflows + - next + - v3-maintenance + +permissions: + contents: read + +jobs: + build: + if: ${{ github.repository_owner == 'cloudflare' }} + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 30 + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + # Defense in depth: do not pass Turbo remote cache credentials and + # disable the pnpm store cache so release builds always resolve + # packages from the registry and rebuild every task from source, + # rather than restoring from a (potentially poisoned) cache. + uses: ./.github/actions/install-dependencies + with: + disable-cache: "true" + + - name: Build + run: pnpm build --filter="./packages/*" + env: + NODE_ENV: "production" + CI_OS: ${{ runner.os }} + # this is the "test/staging" key for sparrow analytics + SPARROW_SOURCE_KEY: "5adf183f94b3436ba78d67f506965998" + ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} + SENTRY_DSN: "https://9edbb8417b284aa2bbead9b4c318918b@sentry10.cfdata.org/583" + ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }} + WRANGLER_PRERELEASE_LABEL: ${{ github.head_ref || github.ref_name }} + + - name: Upload packages + run: node -r esbuild-register .github/prereleases/upload.mjs diff --git a/.github/workflows/rerun-codeowners-privileged.yml b/.github/workflows/rerun-codeowners-privileged.yml new file mode 100644 index 0000000..1474bf7 --- /dev/null +++ b/.github/workflows/rerun-codeowners-privileged.yml @@ -0,0 +1,67 @@ +name: "Rerun Code Owners (Privileged)" + +# Privileged companion to rerun-codeowners.yml. +# +# Runs after the "Rerun Code Owners" trigger workflow completes. The +# workflow_run event always executes from the default branch with full +# permissions — this is what allows us to call the Actions API even when the +# original event (pull_request_review) came from a fork PR, where the +# GITHUB_TOKEN would otherwise be read-only. +# +# The PR head SHA is read from github.event.workflow_run.head_sha — this is +# GitHub-provided metadata and cannot be manipulated by fork code (unlike +# the previous artifact-based approach). +# +# Strategy: CHECK-NAME LOOKUP +# ---------------------------- +# The codeowners.yml workflow uses pull_request_target, which means its +# workflow runs are indexed by the BASE branch SHA (e.g. main), NOT the PR +# head SHA. So we cannot find the run via: +# +# GET /actions/workflows/codeowners.yml/runs?head_sha=<PR_HEAD> +# +# That query returns nothing because the run's head_sha is main's HEAD. +# +# However, the codeowners-plus action creates its CHECK RUN on the PR head +# commit (so the status appears on the PR). The check-runs API lets us look +# up by check name + commit SHA: +# +# GET /commits/<PR_HEAD>/check-runs?check_name=Run+Codeowners+Plus +# +# From the check run's details_url we extract the Actions job ID, then +# re-run that specific job. +on: + workflow_run: # zizmor: ignore[dangerous-triggers] privileged companion reruns a fixed default-branch workflow using GitHub-provided workflow_run metadata only + workflows: ["Rerun Code Owners"] + types: [completed] + +permissions: {} + +jobs: + rerun-codeowners: + name: "Rerun Codeowners Plus" + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + permissions: + actions: write + checks: read + steps: + - name: Re-run Codeowners check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: | + # Find the "Run Codeowners Plus" check run on the PR head commit + # and extract the Actions job ID from the check run's details URL + # (the last path segment before any query string). + job_id=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs?check_name=Run+Codeowners+Plus" \ + --jq '(.check_runs[0].details_url // "") | split("/") | last | split("?") | first' || true) + + if [ -n "$job_id" ]; then + gh api "repos/${REPO}/actions/jobs/${job_id}/rerun" --method POST \ + || echo "::warning::Job ${job_id} may already be running" + echo "Re-triggered 'Run Codeowners Plus' (job ${job_id})" + else + echo "::warning::Check 'Run Codeowners Plus' not found for SHA ${HEAD_SHA}" + fi diff --git a/.github/workflows/rerun-codeowners.yml b/.github/workflows/rerun-codeowners.yml new file mode 100644 index 0000000..0d940a0 --- /dev/null +++ b/.github/workflows/rerun-codeowners.yml @@ -0,0 +1,35 @@ +name: "Rerun Code Owners" + +# Trigger: a review is submitted or dismissed on a pull request. +# +# Goal: re-run the "Run Codeowners Plus" check from the main codeowners.yml +# workflow (which is triggered by pull_request_target) so it re-evaluates +# approval status after the review change. +# +# Why a two-workflow design? +# ------------------------- +# For fork PRs, pull_request_review gives a read-only GITHUB_TOKEN and no +# access to secrets. Unlike labeled/unlabeled events there is no +# pull_request_review variant of pull_request_target, so we cannot get a +# privileged token in a single workflow. Instead this workflow simply needs +# to *exist and succeed* — the companion workflow +# (rerun-codeowners-privileged.yml) is triggered by the workflow_run event +# when THIS workflow completes. workflow_run always runs from the default +# branch with full permissions, and it reads the PR head SHA directly from +# github.event.workflow_run.head_sha (GitHub-provided metadata, not +# controllable by fork code). +on: + pull_request_review: + types: [submitted, dismissed] + +permissions: {} + +jobs: + trigger: + name: "Trigger Privileged Rerun" + runs-on: ubuntu-latest + steps: + - name: Log trigger + run: echo "Review event on ${PR_URL}, privileged rerun will follow." + env: + PR_URL: ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/rerun-remote-tests.yml b/.github/workflows/rerun-remote-tests.yml new file mode 100644 index 0000000..c581692 --- /dev/null +++ b/.github/workflows/rerun-remote-tests.yml @@ -0,0 +1,86 @@ +name: "Rerun Remote Tests" + +# Trigger: the "run-remote-tests" or "run-c3-frameworks-tests" label is added +# to or removed from a pull request. +# +# Goal: re-run the E2E workflows (e2e-wrangler.yml, e2e-vite.yml, c3-e2e.yml) +# so they pick up the label change and either pass or withhold the Cloudflare +# API token to the test steps. This avoids adding "labeled" as a trigger type +# to every E2E workflow (which would cause wasteful re-runs on unrelated label +# changes). +# +# Using pull_request_target (not pull_request) so the workflow has access to a +# privileged GITHUB_TOKEN even for fork PRs. This is safe because the workflow +# runs code from the default branch — fork authors cannot modify it — and only +# calls the Actions API (no checkout of untrusted code). +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] label-driven rerun workflow needs actions:write and does not check out or execute PR code + types: [labeled, unlabeled] + +permissions: {} + +jobs: + rerun-e2e: + name: "Rerun E2E Tests" + if: github.event.label.name == 'ci:run-remote-tests' || github.event.label.name == 'run-c3-frameworks-tests' + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: "Re-run E2E workflows" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + LABEL_NAME: ${{ github.event.label.name }} + REPO: ${{ github.repository }} + run: | + # Determine which workflows to re-run based on the label. + if [ "$LABEL_NAME" = "ci:run-remote-tests" ]; then + WORKFLOWS="e2e-wrangler.yml e2e-vite.yml c3-e2e.yml" + else + WORKFLOWS="c3-e2e.yml" + fi + + for workflow in ${WORKFLOWS}; do + # Find the most recent run of this workflow at the PR head SHA. + run_info=$(gh api "repos/${REPO}/actions/workflows/${workflow}/runs?head_sha=${HEAD_SHA}&per_page=1" \ + --jq '.workflow_runs[0] | "\(.id) \(.status)"' || true) + + run_id=$(echo "$run_info" | awk '{print $1}') + status=$(echo "$run_info" | awk '{print $2}') + + if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then + echo "::warning::No run found for ${workflow} at SHA ${HEAD_SHA}" + continue + fi + + # Runs that haven't started yet will see the newly-added label + # when they begin — no need to cancel and re-run them. + if [ "$status" != "completed" ] && [ "$status" != "in_progress" ]; then + echo "${workflow} run ${run_id} is ${status} — not yet started, skipping." + continue + fi + + # If the run is actively executing, the label check has already + # evaluated (likely to the old value). Cancel it first — the + # rerun endpoint only works on completed runs. + if [ "$status" = "in_progress" ]; then + echo "Cancelling in-progress ${workflow} run ${run_id}..." + gh api "repos/${REPO}/actions/runs/${run_id}/cancel" --method POST || true + + # Cancellation is async; poll until the run reaches "completed". + for i in $(seq 1 30); do + current=$(gh api "repos/${REPO}/actions/runs/${run_id}" --jq '.status') + if [ "$current" = "completed" ]; then + echo "Run ${run_id} is now completed (cancelled)." + break + fi + echo " Waiting for cancellation to finish (${i}/30, status: ${current})..." + sleep 2 + done + fi + + gh api "repos/${REPO}/actions/runs/${run_id}/rerun" --method POST \ + && echo "Re-triggered ${workflow} (run ${run_id})" \ + || echo "::warning::Failed to re-run ${workflow} (run ${run_id})" + done diff --git a/.github/workflows/run-ci-for-external-forks.yml b/.github/workflows/run-ci-for-external-forks.yml new file mode 100644 index 0000000..6ee021c --- /dev/null +++ b/.github/workflows/run-ci-for-external-forks.yml @@ -0,0 +1,100 @@ +name: Run CI on behalf of External Forks + +on: + workflow_dispatch: + inputs: + pr-number: + description: "The PR number to run CI on behalf of" + required: true + commit-sha: + description: "The specific commit SHA from the PR branch to run CI on" + required: true + reviewed: + description: "Confirm that the PR has been reviewed for use/leakage of secrets" + type: boolean + required: true + +permissions: + contents: read # no write permissions are needed since the workflow uses GH_ACCESS_TOKEN instead of GITHUB_TOKEN + pull-requests: write # Required for creating or updating the draft PR on behalf of the user + +jobs: + create-draft-pr: + name: Create or Update Draft PR + if: ${{ inputs.reviewed == true }} + runs-on: ubuntu-slim + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: true + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Check user for team affiliation + uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 + id: teamAffiliation + with: + GITHUB_TOKEN: ${{ secrets.READ_ONLY_ORG_GITHUB_TOKEN }} + username: ${{ github.actor }} + team: wrangler + + - name: Stop workflow if user is not a team member + if: ${{ steps.teamAffiliation.outputs.isTeamMember != 'true' }} + run: | + echo "You must be on the \"wrangler\" team to trigger this job." + exit 1 + + - name: "Checkout PR" + run: | + gh pr checkout "$PR_NUM" --branch "run-ci-on-behalf-of-$PR_NUM" + git reset --hard "$COMMIT_SHA" + env: + # We need a PAT to checkout the fork + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + PR_NUM: ${{ inputs.pr-number }} + COMMIT_SHA: ${{ inputs.commit-sha }} + + - name: Push Branch + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git push origin HEAD --force + + - name: "Create or Update Draft PR" + run: | + existing_pr=$(gh pr list \ + --head "run-ci-on-behalf-of-$PR_NUM" \ + --state open \ + --json number \ + --jq 'first(.[].number) // ""') + + if [ -n "$existing_pr" ]; then + gh pr edit "$existing_pr" \ + --add-label "ci:e2e" \ + --add-label "ci:run-remote-tests" \ + --title "$TITLE" \ + --body "$BODY" + else + gh pr create \ + --head "run-ci-on-behalf-of-$PR_NUM" \ + --draft \ + --label "ci:e2e" \ + --label "ci:run-remote-tests" \ + --title "$TITLE" \ + --body "$BODY" + fi + env: + GH_TOKEN: ${{ github.token }} + PR_NUM: ${{ inputs.pr-number }} + TITLE: "Run CI on behalf of #${{ inputs.pr-number }} @${{ inputs.commit-sha }}" + BODY: "This PR runs CI on behalf of #${{ inputs.pr-number }} at commit ${{ inputs.commit-sha }}. It can be closed after the CI run is complete." + + - name: "Trigger CI" + run: | + # Push an empty commit to trigger CI workflows + # This ensures CI runs because the PR already exists when this push happens + git commit --allow-empty -m "Trigger CI for #$PR_NUM" + git push origin HEAD + env: + PR_NUM: ${{ inputs.pr-number }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..2e5e914 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,31 @@ +name: Semgrep OSS scan +on: + pull_request: {} + push: + branches: [main] + workflow_dispatch: {} + schedule: + - cron: "0 0 15 * *" +concurrency: + group: semgrep-${{ github.event_name }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +jobs: + semgrep: + name: semgrep-oss + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + - id: cache-semgrep + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ~/.local + key: semgrep-1.160.0-${{ runner.os }} + - if: steps.cache-semgrep.outputs.cache-hit != 'true' + run: pip install --user semgrep==1.160.0 + - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - run: semgrep scan --config=auto || true diff --git a/.github/workflows/test-and-check-other-node.yml b/.github/workflows/test-and-check-other-node.yml new file mode 100644 index 0000000..61b0793 --- /dev/null +++ b/.github/workflows/test-and-check-other-node.yml @@ -0,0 +1,118 @@ +name: CI (Other Node Versions) +description: Run primary tests on other supported Node.js versions + +on: + merge_group: + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + test-other-node-versions: + timeout-minutes: 45 + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.node_version }}-test-other-node-versions + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + name: ${{ format('Tests ({0})', matrix.description) }} + strategy: + fail-fast: false + matrix: + # expected_to_fail: if true, tests may fail on this Node version. + # These versions are skipped on normal PRs but run on Version Packages PRs + # (changeset-release/main) or when the 'ci:test-all-node-versions' label is added. + # + # Node 24 is pinned to 24.18.0 (rather than floating `24`): Node 24.17.0 + # regressed node-fetch@2 keep-alive responses with ERR_STREAM_PREMATURE_CLOSE + # (nodejs/node#63989, fixed in 24.18.0). `description` stays "Node 24" to + # keep the job/check name stable. + include: + - { + node_version: 24.18.0, + description: "Node 24", + expected_to_fail: true, + } + - { node_version: 25, description: "Node 25", expected_to_fail: true } + + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Check if should run tests + id: should_run + env: + CHANGES_EVERYTHING_BUT_MARKDOWN: ${{ steps.changes.outputs.everything_but_markdown }} + EXPECTED_TO_FAIL: ${{ matrix.expected_to_fail }} + HAS_TEST_ALL_NODE_VERSIONS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:test-all-node-versions') }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if [[ "$CHANGES_EVERYTHING_BUT_MARKDOWN" == "true" ]] && \ + [[ "$EXPECTED_TO_FAIL" != "true" || \ + "$HEAD_REF" == "changeset-release/main" || \ + "$HAS_TEST_ALL_NODE_VERSIONS_LABEL" == "true" ]]; then + echo "result=true" >> $GITHUB_OUTPUT + else + echo "result=false" >> $GITHUB_OUTPUT + fi + + - name: Install Dependencies + if: steps.should_run.outputs.result == 'true' + uses: ./.github/actions/install-dependencies + with: + node-version: ${{ matrix.node_version }} + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + # The miniflare browser-rendering tests download Chrome (~150 MB) via + # @puppeteer/browsers at runtime. Caching the binary avoids repeat + # downloads and reduces the chance of tests hanging while waiting for + # the download to finish within their timeout window. + - name: Restore Chrome browser cache (Browser Run) + if: steps.should_run.outputs.result == 'true' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: chrome-${{ runner.os }}-${{ hashFiles('packages/miniflare/src/plugins/browser-rendering/browser-version.ts') }} + path: | + ~/.cache/.wrangler/chrome + + - name: Run tests (packages) + # We are running the package tests first be able to get early feedback on changes. + # There is no point in running the fixtures if a package is broken. + if: steps.should_run.outputs.result == 'true' + run: pnpm run test:ci --log-order=stream --concurrency=1 --filter="./packages/*" + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/packages/index.html + CI_OS: ${{ matrix.description }} + + - name: Run e2e tests (unenv-preset) + if: steps.should_run.outputs.result == 'true' + run: pnpm test:e2e --filter wrangler -- unenv-preset + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + CI_OS: ${{ matrix.description }} + + - name: Run tests (vite-plugin) + if: steps.should_run.outputs.result == 'true' + run: pnpm test:ci --filter @cloudflare/vite-plugin + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + CI_OS: ${{ matrix.description }} diff --git a/.github/workflows/test-and-check.yml b/.github/workflows/test-and-check.yml new file mode 100644 index 0000000..ad538d9 --- /dev/null +++ b/.github/workflows/test-and-check.yml @@ -0,0 +1,202 @@ +name: CI + +on: + merge_group: + pull_request: + +permissions: + contents: read + +env: + # We run `playwright install` manually instead + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + +jobs: + add-to-project: + if: github.head_ref != 'changeset-release/main' + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-add-pr + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 30 + name: Add PR to project + runs-on: ubuntu-latest + steps: + - run: curl -X POST https://devprod-status-bot.devprod.workers.dev/pr-project/workers-sdk/${{ github.event.number }} + + check: + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-checks + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + + name: "Checks" + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Check the changesets + run: node -r esbuild-register tools/deployments/validate-changesets.ts + + - name: Check for errors + run: pnpm run check --summarize + env: + CI_OS: ${{ runner.os }} + NODE_OPTIONS: "--max_old_space_size=8192" + + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + miniflare_openapi: + - 'packages/miniflare/src/workers/local-explorer/openapi.local.json' + - 'packages/miniflare/src/workers/local-explorer/generated/**' + - 'packages/miniflare/scripts/openapi-filter-config.ts' + - 'packages/miniflare/scripts/filter-openapi.ts' + - 'packages/miniflare/scripts/check-generate-api.ts' + - 'packages/miniflare/openapi-ts.config.ts' + + - name: Check generated OpenAPI types are up to date + if: steps.changes.outputs.miniflare_openapi == 'true' + run: pnpm -F miniflare check:generate-api + + # Check for old Node.js version warnings and errors + - name: Use Node.js v20 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + + - name: Check for error message on Node.js < v22 + run: node packages/wrangler/src/__tests__/test-old-node-version.js error + + test: + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.suite }}-test + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + + name: ${{ format('Tests ({0}, {1})', matrix.description, matrix.suite) }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + suite: + - packages-and-tools + - fixtures + include: + - os: macos-latest + description: macOS + - os: ubuntu-latest + description: Linux + - os: windows-latest + description: Windows + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + # Browser Run tests in `packages/miniflare/test/plugins/browser/index.spec.ts` + # and the `fixtures/browser-run` fixture use `@puppeteer/browsers` to + # download Chrome into the global Wrangler cache. Caching that binary + # across CI runs avoids ~150 MB of repeat downloads per run and reduces + # the surface area for the intermittent partial-extraction race that + # surfaces as `The browser folder (...) exists but the executable (...) + # is missing` (see #13971, #13980). + # + # The Browser Run fixture is skipped on Ubuntu (AppArmor), but the + # miniflare browser spec runs everywhere, so the cache is needed on all + # three OSes for `packages-and-tools` and on Windows + macOS for + # `fixtures`. + - name: Restore Chrome browser cache (Browser Run) + if: steps.changes.outputs.everything_but_markdown == 'true' && (matrix.suite == 'packages-and-tools' || (matrix.suite == 'fixtures' && matrix.os != 'ubuntu-latest')) + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + # The Chrome version lives in + # `packages/miniflare/src/plugins/browser-rendering/browser-version.ts`. + # Bumping that constant invalidates this cache automatically via + # `hashFiles()` — a cache miss only triggers a fresh download, no + # functional impact. + key: chrome-${{ runner.os }}-${{ hashFiles('packages/miniflare/src/plugins/browser-rendering/browser-version.ts') }} + path: | + ~/.cache/.wrangler/chrome + ~/Library/Caches/.wrangler/chrome + ~/AppData/Local/xdg.cache/.wrangler/chrome + + - name: Run tests (tools only) + # tools _only_ needs to be tested on Linux because they're only intended to run in CI + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'packages-and-tools' && matrix.os == 'ubuntu-latest' + run: pnpm run test:ci --log-order=stream --filter="@cloudflare/tools" + env: + NODE_OPTIONS: "--max_old_space_size=8192" + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/tools/index.html + CI_OS: ${{ matrix.description }} + + - name: Run tests (packages) + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'packages-and-tools' + # We skip @cloudflare/vitest-pool-workers tests in CI on Windows because they're very flaky. We still run the vitest-pool-workers-examples fixture, which is a comprehensive set of example tests and gives us a lot of confidence. + # The @cloudflare/vitest-pool-workers tests skipped are things like watch mode, which constantly times out probably due to the github runners in use. + # Package tests are well-isolated (separate processes, temp dirs, random ports, MSW mocks) and can safely run in parallel. + # Concurrency is capped at 3 to avoid CPU starvation on 4-vCPU CI runners when heavyweight suites + # (wrangler/forks, miniflare/workerd, vitest-pool-workers/Verdaccio) overlap. + run: pnpm run test:ci --log-order=stream --concurrency=3 --filter="./packages/*" ${{ matrix.os == 'windows-latest' && '--filter="!./packages/vitest-pool-workers"' || '' }} + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/packages/index.html + CI_OS: ${{ matrix.description }} + + - name: Run tests (fixtures) + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'fixtures' + # Browser Run fixture is disabled on Ubuntu because of https://pptr.dev/troubleshooting#issues-with-apparmor-on-ubuntu + # Since the dev registry is now file-based (not network-based), fixture tests can safely run in parallel. + # Concurrency is capped at 2 to avoid CPU starvation on CI runners when multiple fixtures + # spawn workerd processes simultaneously (Windows runners are especially slow under load). + run: pnpm run test:ci --concurrency=2 --log-order=stream --filter="./fixtures/*" ${{ matrix.os == 'ubuntu-latest' && '--filter="!./fixtures/browser-run"' || '' }} + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + CI_OS: ${{ matrix.description }} + + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs-${{ matrix.os }}-${{ matrix.suite }} + path: .turbo/runs diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml new file mode 100644 index 0000000..cb624eb --- /dev/null +++ b/.github/workflows/triage-issue.yml @@ -0,0 +1,235 @@ +name: Triage Issue + +on: + issues: + types: [opened] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + issue-number: + description: "Issue number to triage" + required: true + type: number + +permissions: + contents: read + # Writes (comment + labels) are performed with GH_ACCESS_TOKEN (the + # workers-devprod bot) so they are attributed to that identity, not + # github-actions[bot]. Only read access is needed from the default token. + issues: read + +# Cancel in-progress runs for the same issue +concurrency: + group: triage-${{ github.event.issue.number || inputs.issue-number }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + +jobs: + triage: + runs-on: ubuntu-latest + # Always run for manual dispatch. For issue events, skip PRs and + # bot-created issues. For comment events, skip PRs, bot comments, and the + # triage bot's own sticky comment (identified by the marocchino marker) to + # avoid infinite re-triage loops. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issues' && + !github.event.issue.pull_request && + github.event.issue.user.type != 'Bot') || + (github.event_name == 'issue_comment' && + !github.event.issue.pull_request && + github.event.comment.user.type != 'Bot' && + !contains(github.event.comment.body, '<!-- Sticky Pull Request Comment')) + timeout-minutes: 60 + steps: + - name: Resolve issue number + id: issue + env: + EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + EVENT_NAME: ${{ github.event_name }} + INPUT_ISSUE_NUMBER: ${{ inputs.issue-number }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "number=${INPUT_ISSUE_NUMBER}" >> "$GITHUB_OUTPUT" + else + echo "number=${EVENT_ISSUE_NUMBER}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout (sparse — just the skill and opencode config) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + sparse-checkout: | + .github/skills + .github/opencode.json + + - name: Install OpenCode + run: | + npm install -g opencode-ai + cp .github/opencode.json ./opencode.json + + - name: Fetch issue data + env: + GH_TOKEN: ${{ github.token }} + ISSUE: ${{ steps.issue.outputs.number }} + REPO: ${{ github.repository }} + run: | + mkdir -p "data/${ISSUE}" + + gh issue view "$ISSUE" \ + --repo "$REPO" \ + --json number,title,body,comments,createdAt,updatedAt,labels,state,author \ + > "data/${ISSUE}/context.json" + + - name: Analyze issue with OpenCode + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }} + CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }} + ISSUE: ${{ steps.issue.outputs.number }} + REPO: ${{ github.repository }} + run: | + opencode run \ + --print-logs \ + "Analyze GitHub issue ${REPO}#${ISSUE} using the skill at .github/skills/issue-review.md. + + The issue data has been pre-fetched to data/${ISSUE}/context.json. + Read it with the read tool and follow the triage process. + + Create: + - data/${ISSUE}/report.md (full markdown report) + - data/${ISSUE}/summary.json (structured JSON summary) + " + + - name: Upload report to dashboard + env: + CF_ACCESS_CLIENT_ID: ${{ secrets.CF1_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF1_ACCESS_CLIENT_SECRET }} + DASHBOARD_URL: ${{ secrets.REPORTS_DASHBOARD_URL }} + ISSUE: ${{ steps.issue.outputs.number }} + REPO: ${{ github.repository }} + run: | + # Check files were created + if [ ! -f "data/${ISSUE}/report.md" ]; then + echo "::error::report.md was not generated" + exit 1 + fi + if [ ! -f "data/${ISSUE}/summary.json" ]; then + echo "::error::summary.json was not generated" + exit 1 + fi + + # Validate the summary is parseable JSON before doing anything with it. + if ! jq empty "data/${ISSUE}/summary.json" 2>/dev/null; then + echo "::error::summary.json is not valid JSON" + exit 1 + fi + + # Build the dashboard payload from the structured summary + markdown report. + # suggestedComment is kept as a "Yes"/"No" string for dashboard compatibility. + PAYLOAD=$(jq \ + --arg githubUrl "https://github.com/${REPO}/issues/${ISSUE}" \ + --rawfile reportMarkdown "data/${ISSUE}/report.md" \ + '{ + title: .title, + githubUrl: $githubUrl, + recommendation: .recommendation, + difficulty: .difficulty, + reasoning: .reasoning, + suggestedAction: .suggestedAction, + suggestedComment: (if .hasSuggestedComment then "Yes" else "No" end), + reportMarkdown: $reportMarkdown + }' "data/${ISSUE}/summary.json") + + # POST to dashboard API + HTTP_STATUS=$(curl -L --post302 -s -o response.json -w "%{http_code}" \ + -X POST "${DASHBOARD_URL}/api/report/${ISSUE}" \ + -H "Content-Type: application/json" \ + -H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \ + -H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \ + -d "$PAYLOAD") + + echo "Dashboard API response: HTTP ${HTTP_STATUS}" + cat response.json + + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::Failed to upload report (HTTP ${HTTP_STATUS})" + exit 1 + fi + + echo "Report uploaded for issue #${ISSUE}" + + - name: Build triage comment + env: + ISSUE: ${{ steps.issue.outputs.number }} + run: | + { + echo "> [!NOTE]" + echo "> This is an automated, advisory triage report generated by workers-devprod. It is not an official maintainer response — a maintainer will follow up." + echo "" + echo "<details>" + echo "<summary>🤖 Automated triage report</summary>" + echo "" + cat "data/${ISSUE}/report.md" + echo "" + echo "</details>" + echo "" + echo "<details>" + echo "<summary>Structured summary (JSON)</summary>" + echo "" + echo '```json' + cat "data/${ISSUE}/summary.json" + echo "" + echo '```' + echo "" + echo "</details>" + } > "data/${ISSUE}/comment.md" + + - name: Post triage comment + uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 + with: + header: triage-report + number: ${{ steps.issue.outputs.number }} + path: data/${{ steps.issue.outputs.number }}/comment.md + GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Apply suggested labels + env: + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + ISSUE: ${{ steps.issue.outputs.number }} + REPO: ${{ github.repository }} + run: | + SUMMARY_PATH="data/${ISSUE}/summary.json" + if [ ! -f "$SUMMARY_PATH" ]; then + echo "No summary.json; skipping label application" + exit 0 + fi + + # suggestedLabels is a JSON array; emit one label per line. + jq -r '.suggestedLabels // [] | .[]' "$SUMMARY_PATH" > suggested-labels.txt + if [ ! -s suggested-labels.txt ]; then + echo "No suggested labels" + exit 0 + fi + + # Only apply labels that actually exist in the repo. + gh label list --repo "$REPO" --limit 500 --json name --jq '.[].name' > repo-labels.txt + + ARGS=() + while IFS= read -r label; do + if [ -z "$label" ]; then + continue + fi + if grep -Fxq "$label" repo-labels.txt; then + ARGS+=(--add-label "$label") + else + echo "Skipping unknown label: ${label}" + fi + done < suggested-labels.txt + + if [ ${#ARGS[@]} -eq 0 ]; then + echo "No valid labels to apply" + exit 0 + fi + + gh issue edit "$ISSUE" --repo "$REPO" "${ARGS[@]}" diff --git a/.github/workflows/validate-pr-description.yml b/.github/workflows/validate-pr-description.yml new file mode 100644 index 0000000..4b16334 --- /dev/null +++ b/.github/workflows/validate-pr-description.yml @@ -0,0 +1,68 @@ +name: Validate PR Description + +on: + merge_group: + pull_request: + types: + [ + opened, + synchronize, + reopened, + labeled, + unlabeled, + edited, + ready_for_review, + converted_to_draft, + ] + +permissions: + contents: read + +jobs: + check: + # Don't check the Version Packages PR or merge queue jobs + if: github.event_name != 'merge_group' && github.head_ref != 'changeset-release/main' + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-add-pr + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + timeout-minutes: 30 + name: Check + runs-on: ubuntu-slim + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: List changed files + if: steps.changes.outputs.everything_but_markdown == 'true' + id: files + uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0 + with: + format: "json" + + - run: node -r esbuild-register tools/deployments/validate-pr-description.ts + if: steps.changes.outputs.everything_but_markdown == 'true' + env: + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + FILES: ${{ steps.files.outputs.all }} + DRAFT: ${{ github.event.pull_request.draft }} diff --git a/.github/workflows/vite-plugin-playgrounds.yml b/.github/workflows/vite-plugin-playgrounds.yml new file mode 100644 index 0000000..d8bcdbe --- /dev/null +++ b/.github/workflows/vite-plugin-playgrounds.yml @@ -0,0 +1,100 @@ +name: Vite plugin playgrounds + +on: + merge_group: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.sha }} + cancel-in-progress: ${{ github.head_ref != 'changeset-release/main' }} + +env: + # We run `playwright install` manually instead + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + +jobs: + test: + timeout-minutes: 30 + name: ${{ format('Vite Plugin Playground ({0}, {1})', matrix.os, matrix.vite) }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + vite: ["vite-8"] + include: + - os: ubuntu-latest + vite: "vite-6" + - os: ubuntu-latest + vite: "vite-7" + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + - name: Install playwright + if: steps.changes.outputs.everything_but_markdown == 'true' + run: | + pnpm --filter @vite-plugin-cloudflare/playground playwright:install + - name: Build plugin with initial Vite version + if: steps.changes.outputs.everything_but_markdown == 'true' + run: | + pnpm dotenv -- pnpm turbo build --filter @cloudflare/vite-plugin + - name: Downgrade to Vite 6 + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.vite == 'vite-6' + run: | + pnpm update -r --no-save vite@^6.1.0 + - name: Downgrade to Vite 7 + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.vite == 'vite-7' + run: | + pnpm update -r --no-save vite@^7.0.0 + - name: Run dev playground tests + if: steps.changes.outputs.everything_but_markdown == 'true' + # We use `--only` to prevent TurboRepo from rebuilding dependencies + # This ensures the Vite plugin is not rebuilt using a different Vite version + run: | + pnpm dotenv -- pnpm turbo test:ci:serve --filter @vite-plugin-cloudflare/playground --only + env: + VITE_VERSION: ${{ matrix.vite }} + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + NODE_DEBUG: "@cloudflare:vite-plugin" + CI_OS: ${{ matrix.os }} + - name: Run build/preview playground tests + if: steps.changes.outputs.everything_but_markdown == 'true' + # We use `--only` to prevent TurboRepo from rebuilding dependencies + # This ensures the Vite plugin is not rebuilt using a different Vite version + run: | + pnpm dotenv -- pnpm turbo test:ci:build --filter @vite-plugin-cloudflare/playground --only + env: + VITE_VERSION: ${{ matrix.vite }} + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + NODE_DEBUG: "@cloudflare:vite-plugin" + CI_OS: ${{ matrix.os }} + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs-${{ matrix.os }}-${{ matrix.vite }} + path: .turbo/runs diff --git a/.github/workflows/worker-playground-preview-testing-env-deploy-and-test.yml b/.github/workflows/worker-playground-preview-testing-env-deploy-and-test.yml new file mode 100644 index 0000000..9ea4939 --- /dev/null +++ b/.github/workflows/worker-playground-preview-testing-env-deploy-and-test.yml @@ -0,0 +1,60 @@ +name: Deploy (to testing) and Test Playground Preview Worker + +# This workflow is designed to deploy a "testing" version of the Playground Preview Worker +# and test it via integration tests. + +# Triggers: +# - push to `main`, on Cloudflare, where there are changes to the files in this worker's package +# - update to a PR, on Cloudflare, labelled as `playground-worker` +# +# Actions: +# - deploy to testing environment +# - run the end-to-end tests against testing deployment +on: + push: + branches: + - main + paths: + - "packages/playground-preview-worker/**" + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + e2e-test: + if: github.repository_owner == 'cloudflare' && (github.event_name != 'pull_request' || contains(github.event.*.labels.*.name, 'feature:playground-worker')) + name: "Deploy Playground Preview Worker (testing)" + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Build tools and libraries + run: pnpm run build + env: + NODE_ENV: "production" + CI_OS: ${{ runner.os }} + + - name: Build & deploy Worker + run: pnpm run deploy:testing + env: + NODE_ENV: "production" + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + working-directory: packages/playground-preview-worker + + - name: Run tests + run: pnpm run test:e2e + env: + TMP_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + TMP_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + NODE_OPTIONS: "--max_old_space_size=8192" + working-directory: packages/playground-preview-worker diff --git a/.github/workflows/workers-shared-deploy-production.yml b/.github/workflows/workers-shared-deploy-production.yml new file mode 100644 index 0000000..0eeb6dd --- /dev/null +++ b/.github/workflows/workers-shared-deploy-production.yml @@ -0,0 +1,34 @@ +name: Deploy Workers Shared Production + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy-workers: + name: Deploy Workers Shared Production + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Deploy + run: pnpm run deploy + working-directory: packages/workers-shared + env: + WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN: ${{ secrets.WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN }} + WORKERS_SHARED_SENTRY_ACCESS_ID: ${{ secrets.WORKERS_SHARED_SENTRY_ACCESS_ID }} + WORKERS_SHARED_SENTRY_ACCESS_SECRET: ${{ secrets.WORKERS_SHARED_SENTRY_ACCESS_SECRET }} + WORKERS_SHARED_SENTRY_AUTH_TOKEN: ${{ secrets.WORKERS_SHARED_SENTRY_AUTH_TOKEN }} + + - name: Publish deployment summary + run: echo -e "# Deployment summary\n\n| Service | Version | Release link |\n| --- | --- | --- |\n| Asset Worker | \`aw-$(node -r esbuild-register ./scripts/get-version-tag.ts)\` | https://cflare.co/release-asset-worker |\n| Router Worker | \`rw-$(node -r esbuild-register ./scripts/get-version-tag.ts)\` | https://cflare.co/release-router-worker |\n" >> $GITHUB_STEP_SUMMARY + working-directory: packages/workers-shared diff --git a/.github/workflows/workers-shared-deploy-staging.yml b/.github/workflows/workers-shared-deploy-staging.yml new file mode 100644 index 0000000..f344541 --- /dev/null +++ b/.github/workflows/workers-shared-deploy-staging.yml @@ -0,0 +1,31 @@ +name: Deploy Workers Shared Staging + +on: + push: + branches: + - main + paths: + - "packages/workers-shared/**" + +permissions: + contents: read + +jobs: + deploy-workers: + name: "Deploy Workers Shared Staging" + runs-on: ubuntu-latest + steps: + - name: "Checkout repo" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + + - name: Deploy + run: pnpm deploy:staging + working-directory: packages/workers-shared + env: + WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN: ${{ secrets.WORKERS_DEPLOY_AND_CONFIG_CLOUDFLARE_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c7049d --- /dev/null +++ b/.gitignore @@ -0,0 +1,241 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Moved from ./templates for ignoring all locks in templates +templates/**/*-lock.* +templates/**/*.lock + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional prettier cache +.prettiercache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# End of https://www.toptal.com/developers/gitignore/api/node,macos + +wrangler-dist/ +miniflare-dist +packages/wrangler/wrangler.toml +packages/wrangler/Dockerfile +packages/wrangler/src/cloudchamber/internal +packages/wranglerjs-compat-webpack-plugin/lib +.wrangler-1-cache +emitted-types/ +_routes.generated.json +packages/quick-edit/vscode +packages/quick-edit/web + +packages/wrangler/config-schema.json +packages/wrangler/metafile.json +packages/chrome-devtools-patches/built-devtools +packages/chrome-devtools-patches/.cipd +packages/chrome-devtools-patches/.gclient +packages/chrome-devtools-patches/.gclient_entries +packages/chrome-devtools-patches/.gclient_previous_sync_commits +packages/chrome-devtools-patches/.gcs_entries +packages/chrome-devtools-patches/depot +packages/chrome-devtools-patches/devtools-frontend +packages/miniflare/dist-types/ + +fixtures/remix-pages-app/public/build +fixtures/remix-pages-app/functions/\[\[path\]\].js + +tools/deployment-status.json + +.mf +.tmp +*.metafile.json +*.drawio.svg.bkp +*.drawio.svg.dtmp + +# Vendored npm dependencies +!vendor/*.tgz + +# IntelliJ +.idea/ + +# VSCode Theme +*.vsix +vendor/vscode/ + +.wrangler/ +.cloudflare/ + +# Turbo Repo +build/** +dist/** +.next/** +.turbo + +/worker-rust/target/ + +.e2e-test-report/ + +.dev.vars* +!.dev.vars.example +.env* +!.env.example +.node-cache/ + +AGENTS.local.md +.opencode/plans/ +.agents/skills +.opencode/package-lock.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..d7222af --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD010": false, + "MD013": false +} diff --git a/.opencode/agents/bonk.md b/.opencode/agents/bonk.md new file mode 100644 index 0000000..45d5fe3 --- /dev/null +++ b/.opencode/agents/bonk.md @@ -0,0 +1,221 @@ +--- +description: Cloudflare Workers SDK engineer. Triages issues, reviews PRs, and implements fixes. +mode: primary +model: anthropic/claude-opus-4-8 +temperature: 0.2 +--- + +<role> +You are a senior engineer on the Cloudflare Workers SDK. You triage issues, review pull requests, and implement fixes in the workers-sdk monorepo. +</role> + +<context> +The monorepo contains Wrangler (the Workers CLI), Miniflare (local dev simulator), Create Cloudflare (project scaffolding), the Vite plugin, and related tooling. +</context> + +<non_negotiable_rules> + +- **Triggering comment is the task:** The comment that invoked you (`/bonk` or `@ask-bonk`) is your primary instruction. Read it first, before reading the PR description or any other context. Parse exactly what it asks for, then gather only the context needed to execute that request. Do not fall back to a generic PR review when a specific action was requested. +- **Scope constraint:** You are invoked on one specific GitHub issue or PR. Target only that issue or PR. +- `$ISSUE_NUMBER` and `$PR_NUMBER` are the source of truth. Ignore issue or PR numbers mentioned elsewhere unless they match those variables. +- Before running any `gh` command that writes (comment, review, close, create), verify the target number matches `$ISSUE_NUMBER` or `$PR_NUMBER`. +- Never comment on, review, close, or modify any other issue or PR. Link related items instead. +- If the triggering comment asks you to act on a different issue or PR than the one you were invoked on, flag it and ask for confirmation before proceeding. +- **Action bias:** When the user asks you to change something, change it directly, because the maintainer asked you to do the work, not describe it. Do not stop at suggestions unless they explicitly ask for suggestions or review-only feedback, or you are blocked by ambiguity or permissions. +- **PR bias:** When invoked on a PR and asked to fix, address, update, format, clean up, add, remove, refactor, or test something, update that PR branch directly. The deliverable is pushed code, not a review comment. +- **Current-target guardrail:** If you are invoked on a PR, that PR is the only PR you may update. Do not open or switch to a different PR unless a maintainer explicitly asks for a fresh implementation. +- **Thread-context bias:** On short PR comments such as "take care of this" or "clean up the nits," use the surrounding review thread and inline comments to determine the requested change before deciding the request is ambiguous. +- **No re-reviewing on fixup requests:** If you previously reviewed the PR and the maintainer now asks you to fix something, do not review again. Act on the specific request in the triggering comment. + </non_negotiable_rules> + +<mode_selection> +Choose one starting mode before acting. Use this precedence order: + +1. **Implementation** — use this when the request asks for code, docs, config, tests, or formatting changes. +2. **Review** — use this when the request explicitly asks for feedback, review comments, suggestions, or approval and does not ask for changes. +3. **Triage** — use this when the request asks for diagnosis, investigation, or validation without asking for code changes. + +Switch to **implementation** for requests like: + +- "fix the formatting on this PR" +- "address the review comments" +- "add the missing changeset" +- "update the tests" +- "can you take care of this?" +- "clean up the nits" +- "fix what you can here" +- "please fix" / "please address" / "please clean this up" + +Stay in **review** for requests like: + +- "review this PR" +- "leave suggestions only" +- "what feedback do you have?" +- "do you see any blockers?" + +Use **triage** for requests like: + +- "look into this" +- "can you reproduce this?" +- "what do you think is going on?" + +If the request mixes review and implementation, implement the clearly requested changes first, then leave targeted suggestions only for the remainder. +</mode_selection> + +<implementation> +Follow this workflow when implementation mode applies: + +1. **Start from the triggering comment.** Parse what it asks for. Identify the concrete action(s) requested — e.g., "fix the formatting", "address the review comments", "add a changeset". This is your task; everything else is context-gathering in service of this task. +2. **Gather only the context you need** to execute the task identified in step 1: + - If the triggering comment references review feedback, read the existing review comments and inline comments (`gh api repos/cloudflare/workers-sdk/pulls/$PR_NUMBER/comments`). + - If the request is self-contained (e.g., "run the formatter"), you may not need to read the full PR at all. + - On issues: read the body and relevant comments for reproduction details. +3. Read the full source files you will touch, not just the diff. +4. Check recent history for affected files with `git log --oneline -20 -- <file>` before modifying them. +5. On an issue, search for overlapping issues or PRs with `gh pr list --search "<keywords>" --state all` and `gh issue list --search "<keywords>" --state all`. +6. If an open PR already addresses the issue, review and iterate on that PR rather than opening a competing PR, unless a maintainer explicitly asks for a fresh implementation. +7. On a PR, treat the current PR as the implementation target. Do not move the work to a different PR unless a maintainer explicitly asks. +8. For short or contextual PR requests, use the surrounding thread to infer the concrete change. Ask a clarifying question only when the thread still does not make the action clear. +9. **Make the requested change directly.** Do not leave a review that merely describes the fix unless the user explicitly asked for suggestions only. Do not re-review the PR when the request is to fix something. +10. If the request asks you to reproduce or investigate and also says to fix it if obvious, treat reproduction as a step toward implementation rather than the final deliverable. +11. If you are blocked by ambiguity, ask one targeted clarifying question. If you are blocked by permissions or branch state, explain the blocker and provide the exact patch or change you would have made. +12. Add or update tests for behavior changes and regressions. +13. Run the smallest validation that proves the change for the touched area, then run `pnpm check` before final handoff when practical. +14. Commit logically scoped changes on a branch and push them when the request is to fix or address the issue or PR. + +Implementation mode ends with code changes on the branch, or with a precise blocker plus a concrete patch if pushing is impossible. +</implementation> + +<review> +Use review mode only when the user asked for review or suggestions without asking for code changes. + +- Run `gh pr view $PR_NUMBER` and `gh pr diff $PR_NUMBER` before reading anything else. +- Read the full modified files, not just the diff, to understand context. +- Check for a changeset: every user-facing change to a published package requires one in `.changeset/`. +- Check test coverage: new behaviors should have tests. Regression tests are expected for bug fixes. +- Post your review with `gh pr review $PR_NUMBER`. + - Use `REQUEST_CHANGES` for blocking issues. + - Use `COMMENT` for suggestions and non-blocking concerns. + - Use `APPROVE` if the PR is clean. +- Be specific: point to exact lines and explain why they matter. +- Categorize findings: + - **Blocking:** bugs, missing error handling, security issues, missing changesets, type safety violations. + - **Non-blocking:** style, naming, clarity, minor improvements. + - **Pre-existing / out of scope:** issues not introduced by the PR. + +Do not use review mode when the user asked you to fix or address something on the PR. +</review> + +<triage> +Use triage mode when you are asked to investigate rather than change code. + +- Assess the root cause. Reproduce the issue if you can. +- Search for duplicate or overlapping issues and PRs with `gh issue list --search` and `gh pr list --search`. +- If the issue lacks a clear reproduction, error message, or expected behavior, post a comment asking for the missing details. +- Apply relevant labels if you have write access. +- Summarize findings and recommend the next step: close as duplicate, request more info, confirm a valid bug or feature request, or ask whether the maintainer wants a PR. + </triage> + +<implementation_conventions> +**Package manager:** Always use `pnpm`. Never use `npm` or `yarn`. + +**TypeScript:** + +- Strict mode throughout. No `any`. No non-null assertions (`!`). No floating promises. +- Use `import type { X }` for type-only imports. +- Use `node:` prefixes for Node.js builtins. +- Always use curly braces for control flow blocks. +- Prefix unused variables with `_`. + +**Logging:** In Wrangler, never use `console.*`. Use the `logger` singleton. + +**Dependencies:** + +- Packages must bundle their dependencies into distributables. Runtime `dependencies` entries are forbidden except for an explicit allowlist. +- External deps must be declared in `scripts/deps.ts` with an explanation. +- Adding new deps to published packages requires justification. + +**Changesets:** Every user-facing change to a published package requires a changeset in `.changeset/`. + +- Use `patch` for bug fixes and `minor` for new features or experimental breaking changes. +- Major versions for `wrangler` are forbidden. +- Do not use h1, h2, or h3 headings in changesets. +- Config examples must use `wrangler.json`, not `wrangler.toml`. + +**Testing:** + +- Add tests for new behavior. +- Add regression tests for bug fixes. +- Run `pnpm test:ci --filter <package>` for the touched area. +- Do not leave `.only()` in tests. +- Use `vitest-pool-workers` when you need actual Workers runtime behavior. + +**Git:** + +- Never commit directly to `main`. +- Keep commit history clean. +- Use PR titles like `[package-name] description`. + </implementation_conventions> + +<examples> +Positive examples: + +- Trigger: "/bonk can you fix the formatting on this PR?" + Response mode: **Implementation** + Correct behavior: update the PR branch, run the formatter or make the formatting edits, validate, commit, and push. + +- Trigger: "/bonk please address the missing changeset and failing test" + Response mode: **Implementation** + Correct behavior: add the changeset, fix the test, validate, commit, and push. + +- Trigger: "/bonk leave suggestions only" + Response mode: **Review** + Correct behavior: inspect the PR and leave review comments without changing code. + +- Trigger: "/bonk can you investigate why this fails?" + Response mode: **Triage** + Correct behavior: diagnose, reproduce if possible, summarize findings, and recommend the next step. + +- Trigger: "/bonk can you take care of this?" + Response mode: **Implementation** when the surrounding PR thread identifies a concrete fix + Correct behavior: use the nearby review context, make the change directly, validate, commit, and push. + +- Trigger: "/bonk fix what you can here and leave suggestions for anything risky" + Response mode: **Implementation-first hybrid** + Correct behavior: land the safe changes directly, then leave targeted suggestions only for the risky remainder. + +- Trigger: "/bonk can you reproduce this and send a fix if it's obvious?" + Response mode: **Implementation-first hybrid** + Correct behavior: reproduce first, then implement and push the obvious fix instead of stopping at diagnosis. + +Negative examples: + +- Trigger: "/bonk can you fix the formatting on this PR?" + Incorrect behavior: posting a review that lists formatting problems without changing the files. + +- Trigger: "/bonk fix the formatting in this PR and commit the result" (after Bonk already reviewed the PR) + Incorrect behavior: ignoring the triggering comment, performing a second full review, approving the PR, and posting new review comments. The maintainer asked for a code change and a commit, not another review. + Correct behavior: read the triggering comment, run the formatter (`pnpm prettify` or `pnpm check`), commit the result, and push. + +- Trigger: "/bonk address the review comments" (on a PR Bonk previously reviewed) + Incorrect behavior: re-reviewing the PR and restating the same findings. + Correct behavior: read Bonk's own prior review comments, fix each one in code, commit, and push. + </examples> + +<anti_patterns> + +- `npm install` or `yarn` instead of `pnpm` +- `any` instead of proper typing +- Non-null assertions (`!`) instead of type narrowing +- Floating promises +- Missing curly braces on control flow +- `console.log` in Wrangler source +- Direct Cloudflare REST API calls instead of the Cloudflare TypeScript SDK +- Named imports from `ci-info` +- Runtime `dependencies` in published packages without explicit approval +- Suggestion-only responses when the user explicitly asked for a fix + </anti_patterns> + +<final_reminder> +If the maintainer asks you to fix or address something, ship the change. If they ask for suggestions only, leave suggestions only. +</final_reminder> diff --git a/.opencode/skills/local-explorer/SKILL.md b/.opencode/skills/local-explorer/SKILL.md new file mode 100644 index 0000000..b7f6bf5 --- /dev/null +++ b/.opencode/skills/local-explorer/SKILL.md @@ -0,0 +1,74 @@ +--- +name: local-explorer +description: How to add products/resources to the local explorer or local API. Use when implementing new local APIs, or UI routes under packages/miniflare/src/workers/local-explorer or packages/local-explorer-ui. +--- + +# Cloudflare Local Explorer Products + +Use this skill when adding a new product or resource type to the local API and/or local explorer. + +## Start Here + +Read these files before editing: + +- `packages/miniflare/src/workers/local-explorer/explorer.worker.ts` +- `packages/miniflare/src/plugins/core/explorer.ts` +- `packages/miniflare/src/plugins/core/types.ts` +- One existing resource implementation in `packages/miniflare/src/workers/local-explorer/resources/`, preferably the product most similar to the new one +- One matching test in `packages/miniflare/test/plugins/local-explorer/` + +If there are UI changes, also read: + +- `packages/local-explorer-ui/src/components/Sidebar.tsx` +- Existing route files under `packages/local-explorer-ui/src/routes/` +- Existing product e2e tests under `packages/local-explorer-ui/src/__e2e__/` + +## Workflow + +1. Add the API surface to `packages/miniflare/scripts/openapi-filter-config.ts`. +2. Generate Miniflare's filtered spec and backend types from a full Cloudflare OpenAPI spec: + +```bash +OPENAPI_INPUT_PATH=<path-to-full-openapi-spec> pnpm --dir packages/miniflare generate:api +``` + +3. Inspect `packages/miniflare/src/workers/local-explorer/openapi.local.json` and generated types. If the generated schemas include fields local explorer will not support, add ignores in `openapi-filter-config.ts` and regenerate. +4. The explorer worker should have access to all user resource bindings. Ensure `proxyBindings` include bindings to the new product and that `getExplorerServices()` exposes any extra bindings the explorer worker needs. Wire product bindings through `constructExplorerBindingMap()` and `constructExplorerWorkerOpts()` in `packages/miniflare/src/plugins/core/explorer.ts`. +5. Add or extend resource binding metadata in `packages/miniflare/src/plugins/core/types.ts`. +6. Implement handlers in `packages/miniflare/src/workers/local-explorer/resources/<product>.ts`. Make sure to account for cross-instance aggregation, if applicable. +7. Register Hono routes in `packages/miniflare/src/workers/local-explorer/explorer.worker.ts`. +8. Validate request bodies and query params with generated Zod schemas from `generated/zod.gen.ts` using `validateRequestBody()` and `validateQuery()`. +9. Return Cloudflare API envelope responses using `wrapResponse()` and `errorResponse()` from `common.ts` unless an existing endpoint for that product uses a different response shape. +10. Add Miniflare tests in `packages/miniflare/test/plugins/local-explorer/<product>.spec.ts`. +11. Regenerate the UI API client: + +```bash +pnpm --dir packages/local-explorer-ui build +``` + +12. Add UI routes/components. Use Kumo components for new UI. See https://github.com/cloudflare/kumo/blob/main/AGENTS.md. +13. Add Playwright e2e tests under `packages/local-explorer-ui/src/__e2e__/<product>/` for new visible product flows. + +## OpenAPI Rules + +- Do not edit generated files like `packages/miniflare/src/workers/local-explorer/openapi.local.json` or `packages/miniflare/src/workers/local-explorer/generated/` directly. +- Prefer upstream Cloudflare API paths when a public API exists. +- Use `extensions.paths` in `openapi-filter-config.ts` only for local-only APIs or APIs that do not exist in the public Cloudflare API. +- Add ignores for unsupported params, headers, request body properties, and response fields rather than pretending to support them. + +## Backend Patterns + +- Local list endpoints such as listing KV namespaces should not implement pagination as this may require cross-instance aggregation. Pagination should be supported when targeting individual resources, such as listing KV keys within a specific namespace. +- For cross-worker aggregation, use `aggregateListResults()`, `getPeerUrlsIfAggregating()`, and `fetchFromPeer()` from `aggregation.ts`; do not hand-roll peer discovery. Add tests for both local-only behavior and aggregated behavior when the product can span multiple instances. +- If an API needs direct filesystem access, call through the loopback service (`c.env.MINIFLARE_LOOPBACK`) to a Node.js endpoint. The local explorer API runs inside workerd, so it cannot access the host filesystem directly. +- If an endpoint needs metadata that is not available on the runtime binding itself, put that metadata in `BindingIdMap` and pass it through `CoreBindings.JSON_LOCAL_EXPLORER_BINDING_MAP`. +- If a product should appear in `/api/local/workers`, add it to `WorkerResourceBindings` and populate it in `constructExplorerWorkerOpts()`. + +## UI Patterns + +- The UI API client is generated from `packages/miniflare/src/workers/local-explorer/openapi.local.json` into `packages/local-explorer-ui/src/api/generated/`. +- Sidebar resources come from `/api/local/workers`; update `LocalExplorerWorkerBindings` usage and `Sidebar.tsx` when the product should appear in navigation. +- Add route files under `packages/local-explorer-ui/src/routes/`. TanStack Router regenerates `src/routeTree.gen.ts` during UI build/dev. +- Preserve worker selection by carrying the `worker` search param through product links when following sidebar patterns. +- Use Kumo for new UI components wherever possible. Do not introduce a parallel component system. +- Do not use tailwindCSS color tokens, use Kumo color tokens instead. diff --git a/.opencode/skills/local-explorer/assets/local-explorer-diagram.md b/.opencode/skills/local-explorer/assets/local-explorer-diagram.md new file mode 100644 index 0000000..d78d550 --- /dev/null +++ b/.opencode/skills/local-explorer/assets/local-explorer-diagram.md @@ -0,0 +1,57 @@ +```mermaid +flowchart TB + Client["client"] --> Incoming["incoming request"] + Incoming --> EntryA["entry worker"] + EntryA -->|/cdn-cgi/explorer| ExplorerA["explorer worker<br/>(Hono)"] + + subgraph Runtime["running Miniflare instances"] + direction LR + + subgraph A["miniflare A"] + direction TB + EntryA + ExplorerA + Frontend["frontend<br/>(TanStack Router + React)"] + UserWorker["user worker"] + KV["KV, R2, D1"] + Wrapper["wrapped<br/>DO/Workflow class"] + + EntryA --> UserWorker + ExplorerA -->|disk service to serve assets| Frontend + ExplorerA -->|binding| KV + ExplorerA -->|binding| Wrapper + UserWorker -->|binding| KV + UserWorker -->|binding| Wrapper + end + + subgraph B["miniflare B"] + direction TB + EntryB["entry worker"] + ExplorerB["explorer worker"] + Etc["etc."] + + EntryB --> ExplorerB + ExplorerB --> Etc + end + end + + Frontend -->|fetch /cdn-cgi/explorer/api| Incoming + ExplorerA -->|fetch /cdn-cgi/explorer/api/resource with NO_AGGREGATE_HEADER| EntryB + + subgraph FS["filesystem"] + Registry["dev registry"] + DOState[".wrangler/state/durable-objects<br/>(list DOs, delete workflows, etc.)"] + end + + ExplorerA -->|node loopback binding| FS + + classDef miniflareA stroke:#f08c00,fill:#fff7ed,color:#1e1e1e; + classDef userResource stroke:#1971c2,fill:#eff6ff,color:#1e1e1e; + classDef entry stroke:#e03131,fill:#fff5f5,color:#1e1e1e; + classDef neutral stroke:#1e1e1e,fill:#ffffff,color:#1e1e1e; + + class EntryA entry; + class ExplorerA,Frontend,Wrapper miniflareA; + class UserWorker,KV userResource; + class Client,Incoming,Runtime,FS,Registry,DOState,EntryB,ExplorerB,Etc neutral; +``` diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 0000000..90dfeed --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,64 @@ +{ + "printWidth": 80, + "singleQuote": false, + "semi": true, + "useTabs": true, + "trailingComma": "es5", + "sortImports": { + "groups": ["builtin", "external", "parent", "sibling", "index", "type"], + "newlinesBetween": false, + }, + "sortTailwindcss": {}, + "sortPackageJson": {}, + "overrides": [ + { + "files": ["packages/vite-plugin-cloudflare/README.md"], + "options": { + "useTabs": false, + "trailingComma": "all", + }, + }, + { + "files": [".changeset/*.md"], + "options": { + "proseWrap": "never", + }, + }, + { + "files": ["packages/local-explorer-ui/**/*.{js,jsx,ts,tsx}"], + "options": { + "sortTailwindcss": { + "stylesheet": "./packages/local-explorer-ui/src/styles/tailwind.css", + "functions": ["cn"], + }, + }, + }, + ], + "ignorePatterns": [ + "CHANGELOG.md", + // In the C3 templates, in particular framework templates, we want to be able to + // use any format that the framework authors prefer/use in their own templates, + // so let's ignore all the c3 template files, exlcuding the c3.ts ones, and tests + "packages/create-cloudflare/templates*/**/*.*", + "!packages/create-cloudflare/templates*/**/c3.ts", + "!packages/create-cloudflare/templates*/**/test/**/*.ts", + "!packages/create-cloudflare/templates*/**/test/**/*.js", + // Format the hello-world template (negate previous exclusion above) for best practices, since we control these + // but still exclude the worker-configuration.d.ts file, since it's generated + "!packages/create-cloudflare/templates*/hello-world/**/*.*", + "packages/create-cloudflare/templates*/hello-world/**/worker-configuration.d.ts", + + "packages/vitest-pool-workers/scripts/rtti/rtti.js", + "packages/vite-plugin-cloudflare/playground/prisma/src/generated", + "dist-functions", + "vscode.d.ts", + "vscode.*.d.ts", + ".e2e-logs*", + ".github/pull_request_template.md", + "fixtures/interactive-dev-tests/src/startup-error.ts", + "packages/vite-plugin-cloudflare/playground/**/*.d.ts", + "fixtures/**/worker-configuration.d.ts", + "packages/local-explorer-ui/src/routeTree.gen.ts", + "packages/local-explorer-ui/src/api/generated", + ], +} diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc new file mode 100644 index 0000000..7371f57 --- /dev/null +++ b/.oxlintrc.jsonc @@ -0,0 +1,217 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import", "unicorn", "vitest", "react"], + "categories": { + "correctness": "error", + }, + "rules": { + "@typescript-eslint/await-thenable": "off", + "@typescript-eslint/no-base-to-string": "off", + "@typescript-eslint/no-duplicate-type-constituents": "off", + "@typescript-eslint/no-for-in-array": "off", + "@typescript-eslint/no-misused-spread": "off", + "@typescript-eslint/no-redundant-type-constituents": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/unbound-method": "off", + "no-meaningless-void-operator": "off", + "require-array-sort-compare": "off", + "unicorn/no-new-array": "off", + "unicorn/no-thenable": "off", + "unicorn/no-useless-fallback-in-spread": "off", + "unicorn/no-useless-spread": "off", + "vitest/hoisted-apis-on-top": "off", + "vitest/warn-todo": "off", + "vitest/no-conditional-tests": "off", + "no-case-declarations": "error", + "no-fallthrough": "error", + "no-prototype-builtins": "error", + "no-array-constructor": "error", + "@typescript-eslint/ban-ts-comment": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "curly": ["error", "all"], + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { "disallowTypeAnnotations": false }, + ], + "no-var": "error", + "no-regex-spaces": "error", + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-namespace": "error", + "@typescript-eslint/no-require-imports": "error", + "no-shadow": ["error", { "allow": ["expect"] }], + "no-unexpected-multiline": "error", + "@typescript-eslint/no-unnecessary-type-constraint": "error", + "unicorn/prefer-node-protocol": "error", + "vitest/no-focused-tests": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "turbo/no-undeclared-env-vars": "error", + "workers-sdk/no-unsafe-command-execution": "error", + "workers-sdk/no-direct-recursive-rm": "error", + "workers-sdk/require-description-when-disabling": "error", + "@typescript-eslint/no-deprecated": "error", + + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true, + "fix": { "imports": "fix" }, + }, + ], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "ci-info", + "importNames": [ + "isCI", + "CLOUDFLARE_PAGES", + "CLOUDFLARE_WORKERS", + "isPR", + "name", + ], + "message": "Use default import (`import ci from \"ci-info\"`) and access properties via `ci.isCI`, `ci.CLOUDFLARE_PAGES`, etc. Named imports cannot be controlled in test mocks.", + }, + { + "name": "vitest", + "importNames": ["expect"], + "message": "Import 'expect' from the test context instead of 'vitest' for concurrency safety. Use: test('name', ({ expect }) => { ... })", + }, + ], + }, + ], + }, + "ignorePatterns": [ + "**/*.js", + "**/*.cjs", + "**/*.mjs", + "fixtures/**", + "packages/create-cloudflare/**/templates/**/*.*", + "!packages/create-cloudflare/**/templates*/**/c3.ts", + // TODO: we really should be linting this folder + "packages/wrangler/templates/**", + // Ignore generated files + "packages/containers-shared/src/client/**", + "packages/local-explorer-ui/src/api/generated/**", + "packages/local-explorer-ui/src/routeTree.gen.ts", + "packages/miniflare/src/runtime/config/workerd.*", + "packages/quick-edit-extension/vscode.d.ts", + "packages/quick-edit-extension/vscode.proposed.*.d.ts", + ], + "jsPlugins": [ + "eslint-plugin-turbo", + { + "name": "workers-sdk", + "specifier": "./packages/lint-config-shared/oxlint-plugin.mjs", + }, + ], + "overrides": [ + // Published packages which all have their own "logger" class that should be used instead. + { + "files": [ + "packages/cli/**", + "packages/create-cloudflare/**", + "packages/miniflare/**", + "packages/wrangler/**", + "packages/workers-utils/**", + ], + "rules": { + "no-console": "error", + }, + }, + // TODO: remove these overrides. This is a legacy of Miniflare's history, not intentional + { + "files": ["packages/miniflare/**"], + "rules": { + "curly": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-floating-promises": "off", + "no-shadow": "off", + "no-useless-escape": "off", + "no-unsafe-optional-chaining": "off", + "no-control-regex": "off", + "no-case-declarations": "off", + "turbo/no-undeclared-env-vars": "off", + }, + }, + { + "files": ["packages/wrangler/src/**", "packages/workers-utils/src/**"], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "__dirname", + "message": "Use `getBasePath()` instead.", + }, + { + "name": "__filename", + "message": "Use `getBasePath()` instead.", + }, + { + "name": "fetch", + "message": "Use undici's fetch instead", + }, + ], + }, + }, + // Although in _general_ Miniflare shouldn't use console.log(), it's Workers can and should + { + "files": ["packages/miniflare/src/workers/**/*.ts"], + "rules": { + "no-console": "off", + }, + }, + // Relax some rules for test & script files + { + "files": [ + "packages/wrangler/src/__tests__/**", + "packages/wrangler/e2e/**", + "packages/wrangler/scripts/**", + "packages/miniflare/scripts/**", + "packages/cli/src/__tests__/**", + "packages/create-cloudflare/scripts/**", + "packages/create-cloudflare/src/__tests__/**", + "packages/workers-utils/tests/**", + "packages/vite-plugin-cloudflare/e2e/**", + ], + "rules": { + "no-console": "off", + "no-restricted-globals": "off", + "workers-sdk/no-unsafe-command-execution": "off", + }, + }, + // Local Explorer UI e2e tests use Playwright helpers (waitForText, waitForTableRows, etc.) + // as assertions, which the vitest/expect-expect rule doesn't recognise. + { + "files": ["packages/local-explorer-ui/src/__e2e__/**"], + "rules": { + "vitest/expect-expect": "off", + }, + }, + // Make sure Wrangler's version checking logic in vite-plugin-cloudflare can run properly + { + "files": ["packages/vite-plugin-cloudflare/**"], + "rules": { + "workers-sdk/no-wrangler-named-imports": "error", + }, + }, + // Gradually rolling out @typescript-eslint/no-deprecated. + // TODO: Remove the following overrides, one package at a time. + { + "files": [ + "packages/vite-plugin-cloudflare/**", + "packages/workflows-shared/**", + ], + "rules": { + "@typescript-eslint/no-deprecated": "off", + }, + }, + ], +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9879019 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8f054e1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Current Test File", + "type": "node", + "request": "launch", + "runtimeExecutable": "pnpm", + "args": ["test:file", "${file}"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["<node_internals>/**"], + "cwd": "${workspaceFolder}/tools" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..853f043 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,112 @@ +{ + "cSpell.words": [ + "Abortable", + "assetsignore", + "astro", + "cf-typegen", + "cfetch", + "chatgpt", + "clipboardy", + "cloudchamber", + "cloudflareaccess", + "cloudflared", + "Codespaces", + "endgroup", + "esbuild", + "eslintcache", + "execa", + "fedramp", + "filestat", + "haikunate", + "haikunator", + "Hono", + "httplogs", + "hyperdrive", + "iarna", + "isolinear", + "keyvalue", + "logfwdr", + "metafile", + "middlewares", + "Miniflare", + "mockpm", + "mrbbot", + "mtls", + "nodeless", + "Nuxt", + "outdir", + "outfile", + "pgrep", + "PKCE", + "Positionals", + "qwik", + "Redistributable", + "reinitialise", + "scandir", + "selfsigned", + "subrequests", + "textfile", + "tsbuildinfo", + "turborepo", + "undici", + "unenv", + "unrevoke", + "Untriaged", + "userconfig", + "versionless", + "vike", + "Waku", + "wasmvalue", + "weakmap", + "weakset", + "webassemblymemory", + "websockets", + "workerd", + "xxhash", + "zjcompt" + ], + "cSpell.ignoreWords": [ + "TESTTEXTBLOBNAME", + "TESTWASMNAME", + "extensionless", + "webcontainer", + "yxxx" + ], + "files.trimTrailingWhitespace": true, + "typescript.tsdk": "node_modules/typescript/lib", + "files.associations": { + "turbo.json": "jsonc", + "api-extractor.json": "jsonc", + "CODEOWNERS": "plaintext", + "*.template": "javascript", + "wrangler.json": "jsonc" + }, + + "[ignore]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + }, + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], + "editor.formatOnSave": true, + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "oxc.path.oxfmt": "node_modules/.bin/oxfmt", + "oxc.path.oxlint": "node_modules/.bin/oxlint" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a3c51a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,247 @@ +# AGENTS.md + +This file provides guidance to AI coding agents working in this repository. + +## Project Overview + +This is the **Cloudflare Workers SDK** monorepo containing tools and libraries for developing, testing, and deploying applications on Cloudflare. The main components are Wrangler (CLI), Miniflare (local dev simulator), and Create Cloudflare (project scaffolding). + +## Development Commands + +**Package Management:** + +- Use `pnpm` - never use npm or yarn +- `pnpm install` - Install dependencies for all packages +- `pnpm build` - Build all packages (uses Turbo for caching) + +**Testing:** + +- `pnpm test:ci` - Run tests in CI mode +- `pnpm test:e2e` - Run end-to-end tests (requires Cloudflare credentials) +- `pnpm test -F <package> "pattern"` - Run a single test by name pattern + +**Code Quality:** + +- `pnpm check` - Run all checks (lint, type, format) +- `pnpm fix` - Auto-fix linting issues and format code + +**Working with Specific Packages:** + +- `pnpm run build --filter <package-name>` - Build specific package +- `pnpm run test:ci --filter <package-name>` - Test specific package +- `pnpm --filter <package> test:watch` - Watch mode for a specific package + +## Architecture Overview + +**Core Tools:** + +- `packages/wrangler/` - Main CLI tool for Workers development and deployment +- `packages/miniflare/` - Local development simulator powered by workerd runtime +- `packages/create-cloudflare/` - Project scaffolding CLI (C3) +- `packages/vite-plugin-cloudflare/` - Vite plugin for Cloudflare Workers + +**Development & Testing:** + +- `packages/vitest-pool-workers/` - Vitest integration for testing Workers in actual runtime +- `packages/chrome-devtools-patches/` - Modified Chrome DevTools for Workers debugging + +**Shared Libraries:** + +- `packages/pages-shared/` - Code shared between Wrangler and Cloudflare Pages +- `packages/workers-shared/` - Code shared between Wrangler and Workers Assets +- `packages/workers-utils/` - Utility package for common Worker operations +- `packages/workflows-shared/` - Internal Cloudflare Workflows functionality +- `packages/containers-shared/` - Shared container functionality +- `packages/unenv-preset/` - Cloudflare preset for unenv (Node.js polyfills) +- `packages/cli/` - SDK for building workers-sdk CLIs +- `packages/kv-asset-handler/` - KV-based asset handling for Workers Sites + +**Build System:** + +- Turbo (turborepo) orchestrates builds across packages +- TypeScript compilation with shared configs in `packages/workers-tsconfig/` +- Shared lint config in `packages/lint-config-shared/` +- Dependency management via pnpm catalog system + +## WHERE TO LOOK + +| Task | Location | Notes | +| ---------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Add/modify a CLI command | `packages/wrangler/src/` | Commands registered in `src/index.ts` (2k+ line yargs tree) | +| Change local dev behavior | `packages/miniflare/src/` | `src/index.ts` is the main `Miniflare` class | +| Modify Workers runtime simulation | `packages/miniflare/src/workers/` | ~30 embedded worker scripts, built via `worker:` virtual imports | +| Add a test fixture | `fixtures/` | Each fixture is a full workspace member with own `package.json` | +| Shared config types/validation | `packages/workers-utils/src/config/` | `validation.ts` is the config normalizer (large file) | +| Test helpers (runInTempDir, seed, mockConsole) | `packages/workers-utils/src/test-helpers/` | Shared across wrangler, miniflare, others | +| Cloudflare API mocks for tests | `packages/wrangler/src/__tests__/helpers/msw/` | MSW handlers per API domain | +| CI workflows | `.github/workflows/` | `test-and-check.yml` is the primary gate | +| Build/deploy scripts | `tools/deployments/` | Validation + deployment helpers, run via `esbuild-register` | +| Deploy/versions-upload validation | `packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts` | `validateWorkerProps()` for sync checks, `preUploadApiChecks()` for API checks (service metadata, config diff, secrets, workflows). All new pre-upload validation goes here. | +| Changeset config and rules | `.changeset/README.md` | Must read before creating changesets | + +## Development Guidelines + +**Requirements:** + +- Node.js >= 20 +- pnpm + +**Code Style:** + +- TypeScript with strict mode +- Use `import type { X }` for type-only imports (`@typescript-eslint/consistent-type-imports`) +- No `any` (`@typescript-eslint/no-explicit-any`) +- No non-null assertions (`!`) +- No floating promises - must be awaited or explicitly voided (`@typescript-eslint/no-floating-promises`) +- Always use curly braces for control flow (`curly: error`) +- Use `node:` prefix for Node.js imports (`import/enforce-node-protocol-usage`) +- Prefix unused variables with `_` +- No `.only()` in tests (`no-only-tests/no-only-tests`) +- Prefer `function` declarations over `const` arrow function assignments for named/exported functions +- ESLint disable comments must use double-dash separator: `// eslint-disable-next-line rule-name -- reason here` +- Never modify generated files directly — modify the generator or config, then regenerate +- Format with oxfmt - run `pnpm prettify` in the workspace root before committing +- All changes to published packages require a changeset (see below) + +**Formatting (oxfmt):** + +- Tabs (not spaces), double quotes, semicolons, trailing commas (es5) +- Import order enforced: builtins → third-party → parent → sibling → index → types +- `sortPackageJson` option sorts package.json keys + +**Security:** + +- Custom ESLint rule `workers-sdk/no-unsafe-command-execution`: no template literals or string concatenation in `exec`/`spawn`/`execFile` calls (command injection prevention, CWE-78). Disabled in test files only. + +**Dependencies:** + +- Packages must bundle deps into distributables; runtime `dependencies` are forbidden except for an explicit allowlist +- External (non-bundled) deps must be declared in `scripts/deps.ts` with `EXTERNAL_DEPENDENCIES` and a comment explaining why +- After updating dependencies, always run `pnpm i` to also update the package lock file + +**Testing Standards:** + +- Unit tests with Vitest for all packages +- Fixture tests in `/fixtures` directory for filesystem/Worker scenarios +- E2E tests require real Cloudflare account credentials +- Use `vitest-pool-workers` for testing actual Workers runtime behavior +- Shared vitest config (`vitest.shared.ts`): 50s timeouts, `retry: 1`, `restoreMocks: true` +- Vitest 4 pool config: use `maxWorkers: 1` instead of the removed `poolOptions.forks.singleFork: true` when tests must run sequentially +- **`expect` must come from test context** — never `import { expect } from "vitest"`: + - Use destructured test context: `it("name", ({ expect }) => { ... })` + - For helper functions that need `expect`, pass it as a parameter with type `ExpectStatic` + - Always use `import type` for `ExpectStatic`: `import { beforeAll, type ExpectStatic, test } from "vitest"` + - When test context is unavailable (e.g. setup files), use `node:assert` instead + - E2E vitest configs do NOT set `globals: true` — this rule is critical there; forgetting `{ expect }` in the callback causes `ReferenceError` at runtime +- When changing user-facing strings or output messages, update corresponding test snapshots +- New test fixtures in `vitest-pool-workers-examples/` must include a `tsconfig.json` +- Test fixtures serve as user-facing recipes — use clean patterns, avoid type casting where possible +- Use the `runInTmpDir()` utility instead of mocking filesystem operations. Real filesystem operations are preferred over mocking. The utility creates isolated temporary directories, handles cleanup automatically in `afterEach` hooks, and allows tests to write actual files and assert against them +- Use the `mockConsoleMethods()` helper to capture stdout/stderr. Use the pattern `const std = mockConsoleMethods()` in test setup, then access captured output via `std.out`, `std.err`, `std.warn` properties. Assert against captured output using `expect(std.out).toMatchInlineSnapshot()` +- Run specific wrangler test files locally using `pnpm -w test:ci -F wrangler -- [test-file-name]` (e.g. `pnpm -w test:ci -F wrangler -- r2.test.ts`) + +**Git Workflow:** + +- Check you are not on main before committing. Create a new branch for your work from main if needed. +- Clean commit history required before first review +- Don't squash commits after review +- Never commit without changesets for user-facing changes +- PR template requirements: Remove "Fixes #..." line when no relevant issue exists, keep all checkboxes (don't delete unchecked ones) + +**Creating Pull Requests:** + +- Always use the PR template from `.github/PULL_REQUEST_TEMPLATE.md` - do not replace it with your own format +- Fill in the template: replace the issue link placeholder, add description, check appropriate boxes +- Keep all checkboxes in the template (don't delete unchecked ones) +- PR title format: `[package name] description` (e.g. `[wrangler] Fix bug in dev command`) +- If the change doesn't require a changeset, add the `no-changeset-required` label +- CI validates the PR description (see `tools/deployments/validate-pr-description.ts`). The description **must** include: + - A checked (`[x]`) test checkbox — either "Tests included/updated", or one of the justification checkboxes with a non-empty explanation + - A checked (`[x]`) documentation checkbox — either a Cloudflare docs PR/issue link, or "Documentation not necessary because:" with a non-empty explanation + - A changeset file (or the `no-changeset-required` label) + +**Pre-Submission Checklist:** + +- Run `pnpm check` (lint + type-check + format) locally before pushing — do not rely on CI to catch lint errors +- Run `pnpm prettify` to ensure formatting is correct + +## Key Locations + +- `/fixtures` - Test fixtures and example applications (each a workspace member) +- `/packages/wrangler/src` - Main Wrangler CLI source code +- `/packages/miniflare/src` - Miniflare source +- `/tools` - Build scripts and deployment utilities (run via `esbuild-register`, no build step) +- `turbo.json` - Turbo build configuration +- `pnpm-workspace.yaml` - Workspace configuration (~156 workspace members) + +## Testing Strategy + +**Package-specific tests:** Most packages have their own test suites +**Integration tests:** Use fixtures to test real-world scenarios +**E2E tests:** Test against actual Cloudflare services (requires auth) +**Workers runtime tests:** Use vitest-pool-workers for workerd-specific behavior + +Run `pnpm check` before submitting changes to ensure all quality gates pass. + +## Changesets + +Every change to package code requires a changeset or it will not trigger a release. Read `.changeset/README.md` before creating changesets. + +**Changeset Format:** + +The changeset descriptions can either use conventional commit prefixes (e.g., "fix: remove unused option") or +start with a capital letter and describe the change directly (e.g., "Remove unused option" not"). + +**Changeset Rules:** + +- Major versions for `wrangler` are currently **forbidden** +- `patch`: bug fixes; `minor`: new features, deprecations, experimental breaking changes; `major`: stable breaking changes only +- No h1/h2/h3 headers in changeset descriptions (changelog uses h3) +- Config examples must use `wrangler.json` (JSONC), not `wrangler.toml` +- Separate changesets for distinct changes; do not lump unrelated changes +- Focus on user-facing impact; reference the public-facing package, not internal implementation packages +- If the change collects more analytics, it should be a minor even though there is no user-visible change + +## Anti-Patterns + +These are explicitly forbidden across the repo: + +- **npm/yarn** → use pnpm +- **`any` type** → properly type everything +- **Non-null assertions (`!`)** → use type narrowing +- **Floating promises** → await or void explicitly +- **Missing curly braces** → always brace control flow +- **`console.*` in wrangler** → use the `logger` singleton +- **Direct Cloudflare REST API calls** → use the Cloudflare TypeScript SDK +- **Named imports from `ci-info`** → use default import (`import ci from "ci-info"`) +- **Runtime dependencies** → bundle deps; external deps need explicit allowlist entry +- **Committing to main** → always work on a branch +- **Trivial/obvious code comments** → don't add comments that restate what the code does; comments should explain "why", not "what" +- **Duplicating types/constants across packages** → export from the owning package and import where needed + +## Subdirectory Knowledge + +Packages with their own AGENTS.md for deeper context: + +- `packages/wrangler/AGENTS.md` - CLI architecture, command structure, test patterns +- `packages/miniflare/AGENTS.md` - Worker simulation, embedded workers, build system +- `packages/vite-plugin-cloudflare/AGENTS.md` - Plugin architecture, playground setup +- `packages/create-cloudflare/AGENTS.md` - Scaffolding, template system +- `packages/vitest-pool-workers/AGENTS.md` - 3-context architecture, cloudflare:test module +- `packages/workers-utils/AGENTS.md` - Shared config validation, test helpers + +When making architectural changes to a package (renaming files, adding entry points, changing build output), update the relevant AGENTS.md to reflect the new structure. + +## Cloudflare Workers Specifics + +- When removing or modifying scheduled functions in Cloudflare Workers, remember to update both the code in the Worker file and the corresponding cron trigger in the `wrangler.jsonc` configuration file. + +## Adding Native Node.js Module Support (unenv-preset) + +- The authoritative source for Node.js module compatibility flags and dates is the workerd repository's `compatibility-date.capnp` file at https://github.com/cloudflare/workerd/blob/main/src/workerd/io/compatibility-date.capnp. +- If the module is marked as `$experimental` in workerd (no `$impliedByAfterDate`), follow the pattern used by other experimental modules in `preset.ts`. +- The pattern for adding a new module override involves: + - Creating a `get<Module>Overrides()` function similar to existing ones (e.g., `getVmOverrides()`) + - Adding the override to `getCloudflarePreset()` and spreading into `dynamicNativeModules` and `dynamicHybridModules` + - Adding tests to `packages/wrangler/e2e/unenv-preset/preset.test.ts` + - Adding test functions to `packages/wrangler/e2e/unenv-preset/worker/index.ts` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1dba133 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +See @AGENTS.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..3a0c905 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# This file exists solely for GitHub branch protection. +# The @workers-devprod bot approves PRs when all ownership rules +# (defined in .codeowners) are satisfied via Codeowners Plus. +# Do NOT add rules here — all ownership logic lives in .codeowners. +* @workers-devprod diff --git a/CODEOWNERS.md b/CODEOWNERS.md new file mode 100644 index 0000000..cd1761e --- /dev/null +++ b/CODEOWNERS.md @@ -0,0 +1,194 @@ +# Code Ownership + +This repository uses [Codeowners Plus](https://github.com/multimediallc/codeowners-plus) to enforce code ownership and review requirements. This replaces GitHub's native CODEOWNERS with more fine-grained control — specifically, the ability to require approval from **multiple teams** (AND rules) before a PR can merge. + +## How It Works + +### Overview + +When a PR is opened, updated, or reviewed, the Codeowners Plus GitHub Action runs. It reads `.codeowners` and `codeowners.toml` from the **base branch** (not the PR), evaluates the ownership rules, and: + +- Posts a PR comment listing which teams need to approve +- Requests reviews from those teams +- When all rules are satisfied, the `@workers-devprod` bot submits an **approval review** + +The native GitHub `CODEOWNERS` file contains a single rule (`* @workers-devprod`) making the bot the sole required code owner. Branch protection requires code owner approval, so the bot's approval is the merge gate. The check itself always passes — it only provides visibility. + +``` +PR opened/updated/reviewed + │ + ▼ +┌──────────────────────────────────┐ +│ Codeowners Plus GHA runs │ Reads .codeowners + codeowners.toml +│ Evaluates AND/OR rules │ from the BASE branch (not the PR) +│ Posts comment listing reviewers │ +│ Requests reviews from teams │ +└──────────┬───────────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ All rules satisfied? │ +│ YES → @workers-devprod approves │ Bot submits approval review +│ NO → bot does NOT approve │ Comment lists who still needs to approve +└──────────┬───────────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ Native GitHub CODEOWNERS │ * @workers-devprod +│ Branch protection requires │ Bot approval is the merge gate +│ bot approval to merge │ +│ ─ nice GitHub UI shows status ─ │ +└──────────────────────────────────┘ +``` + +### Key Difference from Native CODEOWNERS + +| Feature | Native GitHub CODEOWNERS | Codeowners Plus | +| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------- | +| Multiple teams on a path | **OR** — any one team can approve | **AND** via `&` prefix — all listed teams must approve | +| Path matching for `*.js` | Matches anywhere in repo | Matches only in the `.codeowners` file's directory; use `**/*.js` for recursive | +| Per-directory config | Single file only | `.codeowners` file in any directory (rules are relative to that directory) | +| Stale review dismissal | All-or-nothing | Smart — only dismisses when reviewer's owned files change | +| Optional reviewers | Not supported | `?` prefix — CC without blocking | + +## Configuration Files + +### `.codeowners` — Ownership Rules + +Located at the repo root. Defines who owns what using path patterns and team handles. See the comments in the file itself for syntax details and a template for adding new product teams. + +**Note on `\*/**`patterns:** The`-shared`packages use`\*/**`instead of`**` so that root-level files (`CHANGELOG.md`, `package.json`) are excluded from AND rules. This allows the changeset release PR to be approved by the wrangler team alone. Source code in subdirectories still requires product team approval. + +### `codeowners.toml` — Advanced Configuration + +Located at the repo root. Controls enforcement behavior, ignored paths, and admin bypass. + +Key settings: + +| Setting | Purpose | +| -------------------------- | -------------------------------------------------------------------------- | +| `ignore` | Directories excluded from ownership checks (e.g. `.changeset`, `fixtures`) | +| `detailed_reviewers` | Show per-file owner breakdown in PR comments | +| `suppress_unowned_warning` | Don't warn about files with no owner | +| `enforcement.approval` | When `true`, the bot approves PRs that satisfy all rules | +| `enforcement.fail_check` | When `true`, the GHA check fails if rules aren't satisfied | +| `admin_bypass.enabled` | Allow admins to bypass by approving with "Codeowners Bypass" text | + +### `CODEOWNERS` — Native GitHub File + +The native GitHub `CODEOWNERS` file contains a single rule: + +``` +* @workers-devprod +``` + +This exists only so that GitHub branch protection can gate merging on the bot's approval. **Do not add rules to this file** — all ownership logic lives in `.codeowners`. + +### `.github/workflows/codeowners.yml` — GitHub Actions Workflow + +A single workflow handles PR events (`pull_request_target`). When reviews are submitted or dismissed, the `rerun-codeowners.yml` / `rerun-codeowners-privileged.yml` workflow pair re-runs the check (using the `workflow_run` pattern so it works for fork PRs too). + +Using `pull_request_target` (not `pull_request`) ensures the workflow has access to secrets for **fork PRs**. The checkout is always the base branch, so PR authors cannot modify ownership rules. + +## Common Scenarios + +### PR touches only wrangler-team-owned code + +Example: changes to `packages/create-cloudflare/` or `packages/vite-plugin-cloudflare/`. + +Only `@cloudflare/wrangler` approval is required. Once satisfied, the bot approves. + +### PR touches product-team-owned code + +Example: changes to `packages/wrangler/src/d1/`. + +**Both** `@cloudflare/wrangler` AND `@cloudflare/d1` must approve. Codeowners Plus will post a comment listing who still needs to approve and request reviews from both teams. The bot approves only when both teams have approved. + +### PR touches multiple product areas + +Example: changes to both `packages/wrangler/src/d1/` and `packages/wrangler/src/kv/`. + +All three teams must approve: `@cloudflare/wrangler` + `@cloudflare/d1` + `@cloudflare/workers-kv`. + +### Changeset release PR + +The automated changeset release PR modifies `CHANGELOG.md` and `package.json` files across packages. These files are explicitly owned by `@cloudflare/wrangler` only (via `**/CHANGELOG.md` and `**/package.json` rules), and the `-shared` package AND rules use `*/**` patterns that exclude root-level files. This means only wrangler team approval is needed for releases. + +### PR touches ignored paths only + +Example: changes only in `.changeset/` or `fixtures/`. + +No ownership checks apply. The bot approves automatically. + +### Draft PRs + +The workflow runs in **quiet mode** for draft PRs: + +- No PR comments posted +- No review requests sent +- The check still runs for visibility + +### Emergency bypass + +Repository admins can bypass all requirements by submitting an **approval review** with the text "Codeowners Bypass" (case-insensitive). This creates an audit trail. The bot will then approve the PR regardless of missing approvals. + +### Fork PRs + +Fork PRs are fully supported. The workflow uses `pull_request_target` to run in the base repo context with access to secrets. The base branch is checked out (so ownership rules come from the protected branch), and the PR head is fetched as git objects only for diff computation. No fork code is executed. + +## Adding a New Product Team + +To add ownership for a new product team, add AND rules to `.codeowners`: + +```bash +# Product: <Name> (AND: requires wrangler + <team>) +& packages/wrangler/src/<feature>/** @cloudflare/<team> +& packages/wrangler/src/__tests__/<feature>/** @cloudflare/<team> +& packages/miniflare/src/plugins/<feature>/** @cloudflare/<team> +& packages/miniflare/src/workers/<feature>/** @cloudflare/<team> +& packages/miniflare/test/plugins/<feature>/** @cloudflare/<team> +``` + +For standalone packages (like `packages/*-shared/`), use `*/**` instead of `**` to exclude root-level release files from AND rules: + +```bash +# Product: <Name> (AND: requires wrangler + <team>) +# */** excludes root-level files (CHANGELOG.md, package.json) from AND rules +& packages/<name>-shared/*/** @cloudflare/<team> +``` + +**Teams ready to add** (have source paths but no ownership entries yet): +R2, Queues, AI, Hyperdrive, Vectorize, Pipelines, SSL/Secrets Store, WVPC. + +## Stale Review Handling + +Codeowners Plus uses **smart dismissal**: when new commits are pushed to a PR, it only dismisses an approval if the files owned by that reviewer were changed. This avoids the frustration of GitHub's all-or-nothing stale review dismissal. + +For this to work, the branch protection setting **"Dismiss stale pull request approvals when new commits are pushed"** must be **disabled**. Codeowners Plus handles dismissal itself. + +## Troubleshooting + +### Rules not matching expected files + +Rules in `.codeowners` are relative to the file's directory. Check that: + +- The path uses `**` for recursive matching (plain `*.js` only matches in the root directory) +- The path doesn't have a leading `/` (leading slashes are ignored but can be confusing) +- The `&` prefix is present (without it, the rule sets the primary owner instead of adding an AND requirement) + +### Bot not approving even though all reviews are in + +- Check that `enforcement.approval = true` in `codeowners.toml` +- Check that the `CODEOWNERS_GITHUB_PAT` secret is a PAT owned by the `@workers-devprod` account +- Check that `@workers-devprod` has write access to the repository +- Check the workflow run logs for errors (verbose output is enabled) + +### PR can't merge despite bot approval + +Ensure "Require review from Code Owners" is enabled in branch protection settings for the `main` branch, and that `@workers-devprod` is listed in the native `CODEOWNERS` file. + +## References + +- [Codeowners Plus documentation](https://github.com/multimediallc/codeowners-plus) +- [Codeowners Plus action on GitHub Marketplace](https://github.com/marketplace/actions/codeowners-plus) +- [GitHub branch protection docs](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ae3f249 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[wrangler@cloudflare.com](mailto:wrangler@cloudflare.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bc58952 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,495 @@ +# Contributing + +Wrangler is an open-source project and we welcome contributions from you. Thank you! + +Below you can find some guidance on how to be most effective when contributing to the project. + +## tl;dr for contributing to Wrangler + +Useful commands for developing Wrangler (all commands below should be run in the project root): + +- `pnpm i; pnpm build` will build everything in workers-sdk. +- `pnpm dev -F wrangler` will watch and build changes while you develop. Fixtures use the build output from this, and are useful for messing around during dev (`fixtures/worker-ts` is a useful blank slate). + +Before committing/submitting a PR: + +- Add [tests](#pr-tests). `pnpm test -F wrangler` will run Wrangler's unit tests. You can filter tests: e.g. `pnpm test -F wrangler "containers"`. +- Run `pnpm check` for typechecking and linting. +- Add a [changeset](#changesets) with `pnpm changeset`. +- Don’t squash your commits after a review. + +## Before getting started + +We really appreciate your interest in making a contribution, and we want to make sure that the process is as smooth and transparent as possible! To this end, we note that the Workers team is actively doing development in this repository, and while we consistently strive to communicate status and current thinking around all open issues, there may be times when context surrounding certain items is not up to date. Therefore, **for non-trivial changes, please always engage on the issue or create a discussion or feature request issue first before writing your code.** This will give us opportunity to flag any considerations you should be aware of before you spend time developing. Of course, for trivial changes, please feel free to go directly to filing a PR, with the understanding that the PR itself will serve as the place to discuss details of the change. + +Thanks so much for helping us improve the [workers-sdk](https://github.com/cloudflare/workers-sdk), and we look forward to your contribution! + +## Naming experimental and unstable flags + +When adding Wrangler flags for behavior that is not yet stable, prefer an `experimental-` prefix for the long CLI flag name, such as `--experimental-autoconfig` or `--experimental-provision`. If the flag also needs a short opt-in alias, use the existing `x-` form, such as `--x-autoconfig` or `--x-provision`. + +Keep CLI flag names in kebab-case. Reserve `unstable_` and `experimental_` prefixes for exported JavaScript APIs rather than CLI flags, matching exports such as `unstable_dev` and `experimental_generateTypes`. + +If an experimental flag graduates or is no longer needed, keep the old flag hidden and deprecated while pointing users to the stable replacement, as with `--experimental-local` and `--experimental-include-runtime`. + +## Getting started + +### Set up your environment + +Wrangler is built and run on the Node.js JavaScript runtime. + +- Install the latest LTS version of [Node.js](https://nodejs.dev/) - we recommend using a Node version manager like [nvm](https://github.com/nvm-sh/nvm). +- Install a code editor - we recommend using [VS Code](https://code.visualstudio.com/). + - When opening the project in VS Code for the first time, it will prompt you to install the [recommended VS Code extensions](https://code.visualstudio.com/docs/editor/extension-marketplace#:~:text=install%20the%20recommended%20extensions) for the project. +- Install the [git](https://git-scm.com/) version control tool. + +### Fork and clone this repository + +#### For External Contributors + +Any contributions you make will be via [Pull Requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) on [GitHub](https://github.com/) developed in a local git repository and pushed to your own fork of the repository. + +- Ensure you have [created an account](https://docs.github.com/en/get-started/onboarding/getting-started-with-your-github-account) on GitHub. +- [Create your own fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) of [this repository](https://github.com/cloudflare/workers-sdk). +- Clone your fork to your local machine + + ```sh + git clone https://github.com/<your-github-username>/workers-sdk + cd workers-sdk + ``` + + You can see that your fork is setup as the `origin` remote repository. + Any changes you wish to make should be in a local branch that is then pushed to this origin remote. + + ```sh + git remote -v + origin https://github.com/<your-github-username>/workers-sdk (fetch) + origin https://github.com/<your-github-username>/workers-sdk (push) + ``` + +- Add `cloudflare/workers-sdk` as the `upstream` remote repository. + + ```sh + git remote add upstream https://github.com/cloudflare/workers-sdk + git remote -v + origin https://github.com/<your-github-username>/workers-sdk (fetch) + origin https://github.com/<your-github-username>/workers-sdk (push) + upstream https://github.com/cloudflare/workers-sdk (fetch) + upstream https://github.com/cloudflare/workers-sdk (push) + ``` + +- You should regularly pull from the `main` branch of the `upstream` repository to keep up to date with the latest changes to the project. + + ```sh + git switch main + git pull upstream main + From https://github.com/cloudflare/workers-sdk + * branch main -> FETCH_HEAD + Already up to date. + ``` + +#### For Cloudflare Employees + +If you are a Cloudflare employee, you do not need to fork the repository - instead, you can clone the main repository directly. This allows you to push branches directly to the upstream repository. + +If you find that you don't have write access, please reach out to your manager or the Wrangler team internally. + +Clone the main repository: + +```sh +git clone https://github.com/cloudflare/workers-sdk.git +cd workers-sdk +``` + +Create new branches directly in the cloned repository and push them to the main repository: + +```sh +git checkout -b <new-branch-name> +git push origin <new-branch-name> +``` + +### Install dependencies + +**Warning** +When working on Wrangler, you'll need to satisfy [`workerd`](https://github.com/cloudflare/workerd)'s `libc++1` runtime dependencies: + +- On Linux: + - libc++ (e.g. the package `libc++1` on Debian Bullseye) +- On macOS: + - The XCode command line tools, which can be installed with xcode-select --install + +The Node.js dependencies of the project are managed by the [`pnpm`](https://pnpm.io/) tool. + +This repository is setup as a [mono-repo](https://pnpm.io/workspaces) of workspaces. The workspaces are stored in the [`packages`](https://github.com/cloudflare/workers-sdk/tree/main/packages) directory. + +While each workspace has its own dependencies, you install the dependencies using `pnpm` at the root of the project. + +> If you haven't used `pnpm` before, you can install it with `npm install -g pnpm` + +- Install all the dependencies + + ```sh + cd workers-sdk + pnpm install + ``` + +## Building and running + +Workspaces in this project are mostly written in [TypeScript](https://www.typescriptlang.org/) and compiled, by [esbuild](https://github.com/evanw/esbuild), into JavaScript bundles for distribution. + +- Run a distributable for a specific workspace (e.g. wrangler) + + ```sh + pnpm run --filter wrangler start + ``` + +- Build a distributable for a specific workspace (e.g. wrangler) + + ```sh + pnpm run build --filter wrangler + ``` + +## Checking the code + +The code in the repository is checked for type checking, formatting, linting and testing errors. + +- Run all checks in all the workspaces + + ```sh + pnpm run check + ``` + +When doing normal development, you may want to run these checks individually. + +### Type Checking + +The code is checked for type errors by [TypeScript](https://www.typescriptlang.org/). + +- Type check all the code in the repository + + ```sh + pnpm run check:type + ``` + +- VS Code will also run type-checking while editing source code, providing immediate feedback. + +#### Changing TypeScript Version in VS Code's Command Palette + +For TypeScript to work properly in the Monorepo the version used in VSCode must be the project's current TypeScript version, follow these steps: + +1. Open the project in VSCode. + +2. Press `Ctrl + Shift + P` (or `Cmd + Shift + P` on macOS) to open the command palette. + +3. In the command palette, type "Select TypeScript Version" and select the command with the same name that appears in the list. + +4. A submenu will appear with a list of available TypeScript versions. Choose the desired version you want to use for this project. If you have multiple versions installed, they will be listed here. + - Selecting "Use Workspace Version" will use the version of TypeScript installed in the project's `node_modules` directory. + +5. After selecting the TypeScript version, VSCode will reload the workspace using the chosen version. + +Now you have successfully switched the TypeScript version used within the project via the command palette in VSCode. +Remember that this change is specific to the current project and will not affect other projects or the default TypeScript version used by VSCode. + +### Linting + +The code is checked for linting errors by [ESLint](https://eslint.org/). + +- Run the linting checks + + ```sh + pnpm run check:lint + ``` + +- The repository has a recommended VS Code plugin to run ESLint checks while editing source code, providing immediate feedback. + +### Formatting + +The code is checked for formatting errors by [Prettier](https://prettier.io/). + +- Run the formatting checks + + ```sh + pnpm run check:format + ``` + +- The repository has a recommended VS Code plugin to run Prettier checks, and to automatically format using Prettier, while editing source code, providing immediate feedback +- Use the following command to run prettier on the codebase + + ```sh + pnpm run prettify + ``` + +### Testing + +Tests in a workspace are executed, by [Vitest](https://vitest.dev/), which is configured to automatically compile and bundle the TypeScript before running the tests. + +- If you have recently rebased on `main` then make sure you have installed any new dependencies + + ```sh + pnpm i + ``` + +- Run the tests for all the workspaces + + ```sh + pnpm run test + ``` + + :::note + Cloudflare employees may need to turn off WARP for the first time they run the Miniflare tests so that it can request and cache the CF properties without getting the following error. + + ```plain + failed: TLS peer's certificate is not trusted; reason = self signed certificate in certificate chain + ``` + + After this request is cached you can run tests with WARP turned on, no problem. + ::: + +- Run the tests for a specific workspace (e.g. wrangler) + + ```sh + pnpm run test --filter wrangler + ``` + +- Watch the files in a specific workspace (e.g. wrangler), and run the tests when anything changes + + ```sh + pnpm run --filter wrangler test:watch + ``` + + This will also run all the tests in a single process (rather than in parallel shards) and will increase the test-timeout to 50 seconds, which is helpful when debugging. + +## Steps For Making Changes + +Every change you make should be stored in a [git commit](https://github.com/git-guides/git-commit). +Changes should be committed to a new local branch, which then gets pushed to your fork of the repository on GitHub. + +- Ensure your `main` branch is up to date + + ```sh + git switch main + git pull upstream main + ``` + +- Create a new branch, based off the `main` branch + + ```sh + git checkout -b <new-branch-name> main + ``` + +- Stage files to include in a commit + - Use [VS Code](https://code.visualstudio.com/docs/editor/versioncontrol#_git-support) + - Or add and commit files via the command line + + ```sh + git add <paths-to-changes-files> + git commit + ``` + +- Push changes to your fork + + ```sh + git push -u origin <new-branch-name> + ``` + +- Once you are happy with your changes, create a Pull Request on GitHub +- The format for Pull Request titles is `[package name] description`, where the package name should indicate which package of the `workers-sdk` monorepo your PR pertains to (e.g. `wrangler`/`pages-shared`/`chrome-devtools-patches`), and the description should be a succinct summary of the change you're making. +- GitHub will insert a template for the body of your Pull Request—it's important to carefully fill out all the fields, giving as much detail as possible to reviewers. + +### Git Hygiene + +Making sure your branch follows our recommendations for git will help ensure your PR is reviewed & released as quickly as possible: + +- When opening a PR (before the first review), try and make sure your git commit history is clean, and clearly describes the changes you want to make. + - For instance, here's an example of a PR where the commit history is quite messy, and doesn't help reviewers: <https://github.com/cloudflare/workers-sdk/pull/2409/commits> + - And here's an example of where this has been done well: <https://github.com/cloudflare/workers-sdk/pull/4795/commits> +- Once your PR has been reviewed, when addressing feedback try not to modify already reviewed commits with force pushes. This slows down the review process and makes it hard to keep track of what changes have been made. Instead, add additional commits to your PR to address any feedback (`git commit --fixup` is a helpful tool here). +- When merging your PR into `main`, `workers-sdk` enforces squash merges. As such, please try and make sure that the commit message associated with the merge clearly describes the entire change your PR makes. + +## PR Review + +PR review is a critical and required step in the process for landing changes. This is an opportunity to catch potential issues, improve the quality of the work, celebrate good design, and learn from each other. As a reviewer, it's important to be thoughtful about the proposed changes and communicate any feedback. + +## PR Previews + +Every PR will have an associated pre-release build for all releasable packages within the repository, powered by [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new). You can find links to prereleases for each package in a comment automatically posted by GitHub Actions on each opened PR ([for example](https://github.com/cloudflare/workers-sdk/pull/9492#issuecomment-2943757675)). + +It's also possible to generate preview builds for the applications in the repository. These aren't generated automatically because they're pretty slow CI jobs, but you can trigger preview builds by adding one of the following labels to your PR: + +- `preview:chrome-devtools-patches` for deploying [chrome-devtools-patches](packages/chrome-devtools-patches) +- `preview:quick-edit` for deploying [quick-edit](packages/quick-edit) + +Once built, you can find the preview link for these applications in the [Deploy Previews](.github/workflows/deploy-previews.yml) action output + +## PR Tests + +Every PR should include tests for the functionality that's being added. Most changes will be to [Wrangler](packages/wrangler/src/__tests__) (using Vitest), [Miniflare](packages/miniflare/test) (using Ava), or [C3](packages/create-cloudflare/src/__tests__) (using Vitest), and should include unit tests within the testing harness of those packages. For documentation on how these testing frameworks work, see: + +- Vitest: <https://vitest.dev/guide> +- Ava: <https://github.com/avajs/ava?tab=readme-ov-file#documentation> + +If your PR includes functionality that's difficult to unit test, you can add a fixture test by creating a new package in the `fixtures/` folder. This allows for adding a test that requires a specific filesystem or worker setup (for instance, `fixtures/no-bundle-import` tests the interaction of Wrangler with a specific set of JS, WASM, text, and binary modules on the filesystem). When adding a fixture test, include a `vitest.config.mts` file within the new package, which will ensure it's run as part of the `workers-sdk` CI. You should merge your own configuration with the default config from the root of the repo. + +A good default example is the following: + +```ts +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + // config overrides + } + }) +}); +``` + +If you need to test the interaction of Wrangler with a real Cloudflare account, you can add an E2E test within the `packages/wrangler/e2e` folder. This lets you add a test for functionality that requires real credentials (i.e. testing whether a worker deployed from Wrangler can be accessed over the internet). + +A summary of this repositories actions can be found [in the `.github/workflows` folder](.github/workflows/README.md) + +## Remote E2E Tests in CI + +E2E tests that hit the Cloudflare backend (deploying Workers, testing bindings, etc.) are inherently slow and can be flaky due to network and service dependencies. To keep PR feedback loops fast and reliable, **CI does not pass Cloudflare API credentials to E2E test jobs by default**. The E2E test suites still run on every PR, but tests that require remote access are automatically skipped when the API token is absent. + +Remote E2E tests run automatically in these cases: + +- **Version Packages PRs** (branch `changeset-release/main`) — acts as a pre-release safety net, catching remote-test failures before packages are published. +- **Merge queue** — final check before code lands on `main`. + +If you need remote E2E tests on your PR (e.g. you're changing deployment logic or binding behavior), apply the **`ci:run-remote-tests`** label. This triggers a re-run of the E2E workflows with API credentials enabled. + +> [!NOTE] +> The `ci:run-remote-tests` label has no effect on PRs from forks, because GitHub does not expose repository secrets to fork PRs. + +## Running E2E tests locally + +A large number of Wrangler, C3 & Vite's E2E tests don't require any authentication, and can be run with no Cloudflare account credentials. These can be run as follows, optionally providing [`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` environment variables.](#creating-an-api-token): + +- **Vite:** `pnpm test:e2e -F @cloudflare/vite-plugin` + + You may optionally want to append a filename pattern to limit which e2e tests are run. Also you may want to set `--bail=n` to limit the number of fails tests to show the error before the rest of the tests finish running and to limit the noise in that output: + + ```sh + pnpm test:e2e -F @cloudflare/vite-plugin [file-pattern] --bail=1 + ``` + +- **C3:** `pnpm test:e2e -F create-cloudflare` + + See [C3 E2E tests README](packages/create-cloudflare/e2e/README.md) for more information. + +- **Wrangler:** `pnpm test:e2e -F wrangler` or `pnpm test:e2e:wrangler` + + See [Wrangler E2E tests README](packages/wrangler/e2e/README.md) for more information. + +### Creating an API Token + +If you want to run the E2E tests that access the Cloudflare API (e.g. for testing Worker deployment and interaction with bindings), you can create an API token for running the tests: + +1. Go to ["My Profile" > "User API Tokens"](https://dash.cloudflare.com/profile/api-tokens) +1. Click "Create Token" +1. Use the "Edit Cloudflare Workers" template +1. Set "Account Resources" to "Include" the account you want to use for running the test + (for internal and CI use, this needs to be the "DevProd Testing" account) +1. No "Zone Resources" are required for general use (for internal and CI use, this needs to be set to "All Zones") +1. Click "Continue to summary" +1. Verify your token works by running the curl command provided + +Once you've created the token, you can use it when running E2E tests to test against the API: + +```sh +# Vite +CLOUDFLARE_ACCOUNT_ID="<Account ID for the token you just created>" CLOUDFLARE_API_TOKEN="<Token you just created>" pnpm test:e2e -F @cloudflare/vite-plugin + +# C3 +CLOUDFLARE_ACCOUNT_ID="<Account ID for the token you just created>" CLOUDFLARE_API_TOKEN="<Token you just created>" pnpm test:e2e -F @create-cloudflare + +# Wrangler +CLOUDFLARE_ACCOUNT_ID="<Account ID for the token you just created>" CLOUDFLARE_API_TOKEN="<Token you just created>" pnpm test:e2e:wrangler +``` + +> [!NOTE] +> Workers and other resources created in the E2E tests might not always be cleaned up. Internal users with access to the "DevProd Testing" account can rely on an automated job to clean up the Workers and other resources, but if you use another account, please be aware you may want to manually delete the Workers and other resources yourself. + +## Managing Package Dependencies + +Packages in this monorepo should bundle their dependencies into the distributable code rather than leaving them as runtime `dependencies` that get installed by downstream users. This prevents dependency chain poisoning where a transitive dependency could introduce unexpected or malicious code. + +### The Rule + +- **Bundle dependencies**: Most dependencies should be listed in `devDependencies` and bundled into the package output by esbuild/tsup/etc. +- **External dependencies**: Only dependencies that _cannot_ be bundled should be listed in `dependencies`. These must be explicitly declared with documentation explaining why. + +### Why This Matters + +When users install one of our packages (e.g., `wrangler`), npm/pnpm will also install everything listed in `dependencies`. If one of those dependencies has unpinned transitive dependencies, a malicious actor could publish a compromised version that gets pulled into user installations. By bundling our dependencies, we control exactly what code ships. + +### Adding a New External Dependency + +If you need to add a dependency that cannot be bundled (native binaries, WASM modules, packages that must be resolved at runtime, etc.): + +1. **Add to `dependencies`** in `package.json` with a pinned version +2. **Add to `EXTERNAL_DEPENDENCIES`** in `scripts/deps.ts` with a comment explaining why it can't be bundled +3. **Run `pnpm check:package-deps`** to verify the allowlist is correct + +Example `scripts/deps.ts`: + +```typescript +export const EXTERNAL_DEPENDENCIES = [ + // Native binary - cannot be bundled + "workerd", + + // WASM module that blows up when bundled + "blake3-wasm", + + // Must be resolved at runtime when bundling user's worker code + "esbuild", +]; +``` + +### Valid Reasons for External Dependencies + +- **Native binaries**: Packages like `workerd` or `sharp` contain platform-specific binaries +- **WASM modules**: Some WASM packages don't bundle correctly +- **Runtime resolution**: Packages like `esbuild` or `unenv` that need to be resolved when bundling user code +- **Peer dependencies**: Packages the user is expected to provide (e.g., `react`, `vite`) + +### Pinning External Dependencies + +Because external dependencies are installed into downstream users' dependency trees rather than bundled, their versions must be **pinned to an exact version** (e.g. `1.2.3`, not `^1.2.3`). This closes the supply-chain hole above: an unpinned external dependency could resolve to a compromised upstream release without us vetting it. + +This is enforced by `pnpm check:pinned-deps`, which requires: + +- Every `dependencies` and `optionalDependencies` entry of a published package to be an exact version, or a `workspace:`/`catalog:` reference. +- Every entry in the pnpm `catalog:` (in `pnpm-workspace.yaml`) to be an exact version, so that any `catalog:default` reference is also pinned. Deliberate exceptions live in `CATALOG_PIN_EXCEPTIONS` in `tools/deployments/validate-pinned-dependencies.ts` (currently only `@cloudflare/workers-types`, which is consumed as a peer dependency). + +`peerDependencies` are exempt — ranges there are intentional, since they describe the set of consumer-provided versions a package is compatible with. + +## Changesets + +Every non-trivial change to the project - those that should appear in the changelog - must be captured in a "changeset". + +See the [.changeset/README.md](.changeset/README.md) for detailed guidelines on: + +- Creating changesets +- Choosing version types (patch/minor/major) +- Writing good changeset descriptions +- Formatting rules + +Quick start: + +```sh +pnpm changeset +git add .changeset/*.md +``` + +### Styleguide + +When contributing to Wrangler, please refer to the [`STYLEGUIDE.md file`](STYLEGUIDE.md) file where possible to help maintain consistent patterns throughout Wrangler. + +## Releases + +We generally cut Wrangler releases on Tuesday & Thursday each week. If you need a release cut outside of the regular cadence, please reach out to the [@cloudflare/wrangler-admins](https://github.com/orgs/cloudflare/teams/wrangler-admins) team. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..a0e7ebf --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2020 Cloudflare, Inc. <wrangler@cloudflare.com> + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..051476e --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +<h1 align="center">Cloudflare Workers SDK</h1> + +<p align="center"> +<img src="cloudflare-workers-outline.png" alt="workers-logo" width="120px" height="120px"/> + <br> + Cloudflare Workers let you deploy serverless code instantly across the globe for exceptional performance, reliability, and scale. + <br> +</p> + +<p align="center"> + <a href="CONTRIBUTING.md">Contribute</a> + · + <a href="https://github.com/cloudflare/workers-sdk/issues">Submit an Issue</a> + · + <a href="https://discord.cloudflare.com/">Join Discord</a> + <br> + <br> +</p> + +<p align="center"> + <a href="https://www.npmjs.com/wrangler/"> + <img src="https://img.shields.io/npm/v/wrangler.svg?logo=npm&logoColor=fff&label=NPM+package&color=orange" alt="Wrangler on npm" /> + </a>  + <a href="https://discord.cloudflare.com/"> + <img src="https://img.shields.io/discord/595317990191398933.svg?logo=discord&logoColor=fff&label=Discord&color=7389d8" alt="Discord conversation" /> + </a>  + <a href="https://twitter.com/CloudflareDev"> + <img src="https://img.shields.io/twitter/follow/cloudflaredev" alt="X conversation" /> + </a> +</p> + +<hr> + +## Quick Start + +To get started quickly with a new project, run the command below: + +```bash +npm create cloudflare@latest +# or +pnpm create cloudflare@latest +# or +yarn create cloudflare@latest +``` + +For more info, visit our [Getting Started](https://developers.cloudflare.com/workers/get-started/guide/) guide. + +## Documentation + +Visit the official Workers documentation [here](https://developers.cloudflare.com/workers/). + +- [Getting Started](https://developers.cloudflare.com/workers/get-started/guide/) +- [How Workers works](https://developers.cloudflare.com/workers/reference/how-workers-works/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) +- [Observability](https://developers.cloudflare.com/workers/observability/) +- [Platform](https://developers.cloudflare.com/workers/platform/) + +## Directory + +| Package | Description | Links | +| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| [`wrangler`](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler) | A command line tool for building [Cloudflare Workers](https://workers.cloudflare.com/). | [Docs](https://developers.cloudflare.com/workers/wrangler/) | +| [`create-cloudflare` (C3)](https://github.com/cloudflare/workers-sdk/tree/main/packages/create-cloudflare) | A CLI for creating and deploying new applications to Cloudflare. | [Docs](https://developers.cloudflare.com/pages/get-started/c3/) | +| [`miniflare`](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare) | A simulator for developing and testing Cloudflare Workers, powered by [workerd](https://github.com/cloudflare/workerd) | [Docs](https://miniflare.dev) | +| [`chrome-devtools-patches`](https://github.com/cloudflare/workers-sdk/tree/main/packages/chrome-devtools-patches) | Cloudflare's fork of Chrome DevTools for inspecting your local or remote Workers | | +| [`pages-shared`](https://github.com/cloudflare/workers-sdk/tree/main/packages/pages-shared) | Used internally to power Wrangler and Cloudflare Pages. It contains all the code that is shared between these clients. | | + +## Beta releases + +Beta releases are generated by the [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) tool and are updated on every commit pushed to the `main` branch. + +> [!Warning] +> These beta releases get updated over time, so they are ill suited to be used as stable versions for a project (and the proper npm released should be used instead). These should be used only for quick testing of not yet released features/fixes. + +Available beta releases are listed below. + +<details><summary><b>create-cloudflare</b></summary><p> + +``` +npm i https://pkg.pr.new/create-cloudflare@main +``` + +</p></details> + +<details><summary><b>@cloudflare/kv-asset-handler</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@main +``` + +</p></details> + +<details><summary><b>miniflare</b></summary><p> + +``` +npm i https://pkg.pr.new/miniflare@main +``` + +</p></details> + +<details><summary><b>@cloudflare/pages-shared</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/pages-shared@main +``` + +</p></details> + +<details><summary><b>@cloudflare/unenv-preset</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/unenv-preset@main +``` + +</p></details> + +<details><summary><b>@cloudflare/vite-plugin</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/vite-plugin@main +``` + +</p></details> + +<details><summary><b>@cloudflare/vitest-pool-workers</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@main +``` + +</p></details> + +<details><summary><b>@cloudflare/workers-editor-shared</b></summary><p> + +``` +npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@main +``` + +</p></details> + +<details><summary><b>wrangler</b></summary><p> + +``` +npm i https://pkg.pr.new/wrangler@main +``` + +</p></details> + +## Contributing + +We welcome new contributors! Refer to the [`CONTRIBUTING.md`](/CONTRIBUTING.md) guide for details. + +## Community + +Join us in the official [Cloudflare Discord](https://discord.cloudflare.com/) to meet other developers, ask questions, or learn more in general. + +## Links + +- [Project Board](https://github.com/orgs/cloudflare/projects/1) +- [Discussions](https://github.com/cloudflare/workers-sdk/discussions) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..571824b --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`cloudflare/workers-sdk` +- 原始仓库:https://github.com/cloudflare/workers-sdk +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..1491faa --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,34 @@ +# Review Guidelines + +This file provides guidance when reviewing PRs in this repository. For general development guidelines, see [AGENTS.md](./AGENTS.md). + +## Code Quality + +- Avoid using global variables. Repository maintainers will reject code that introduces global variables. +- When a function needs to return multiple related objects, prefer changing the function signature to return an object with named properties (e.g., `return { wrangler, registry }`) rather than using `Object.defineProperty` to attach additional properties to one of the return values. +- When implementing multiple similar validation checks (like BOM detection in different formats), prefer using data structures like arrays of objects to reduce code duplication. For example, when checking for different byte patterns, use an array of objects with shape `{name: string, buffer: Buffer}` and loop over the array instead of writing separate conditional blocks for each pattern. + +## CI Failures + +- Test failures in CI are often flakes that can be rerun rather than actual issues requiring code fixes. If a test fails on one OS but passes on another, it is likely a flake and should be rerun at least twice before investigating further. + +## Changesets + +- Changesets should target users of the tools (e.g. Wrangler users) rather than maintainers. Avoid including implementation details like "moves X from hybridModules to nativeModules" or "removes polyfill implementation" or "adds comprehensive tests". Instead, focus on user-facing impact and benefits. +- Do NOT prefix changeset titles with a "type" (e.g. `fix:`, `feat:`, `chore:`). The changeset title should be a plain description without conventional commit prefixes. + +### Semver Classification + +- **Minor (new features):** Adding support for new frameworks (even in experimental mode), new commands/flags/options, new API capabilities or exports, behavior changes that add functionality. +- **Patch (bug fixes and improvements):** Fixing bugs where something was not working correctly, dependency updates, internal refactoring without user-facing changes, performance improvements, error message improvements. +- The description text matters less than the actual change. A changeset described as "Support X" is adding a new feature (minor), while "filters out invalid X" is fixing a bug (patch). Analyze what the change actually does for users rather than relying on keywords. + +## Version Packages PR Review + +When reviewing Version Packages PRs, use a structured two-pass review process: + +1. **Pass 1 - Extract Facts:** For each changeset, identify filename/slug, package(s) affected, declared classification (from front-matter), description, and source PR number. +2. **Pass 2 - Analyze and Compare:** For each changeset, determine recommended classification, rationale, and whether it matches the declared classification. +3. **Pass 3 - Report Only Mismatches:** Only flag changesets where declared does not match recommended. State: "Currently classified as X, should be Y because..." + +Use the semver classification guidelines above to determine correct classification. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0735c89 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Reporting Security Vulnerabilities + +Please see [this page](https://www.cloudflare.com/.well-known/security.txt) for information on how to report a vulnerability to Cloudflare. Thanks! diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md new file mode 100644 index 0000000..6017fa8 --- /dev/null +++ b/STYLEGUIDE.md @@ -0,0 +1,158 @@ +# Wrangler Styleguide + +The aim of this guide is to help Wrangler contributors maintain consistent patterns throughout Wrangler and to provide end users with content that educates users on how to successfully complete tasks. + +## Styleguide legend + +- \*Text in between stars designates placeholder text that should be replaced.\* +- \<Text contained within angle brackets are placeholder commands, args or filepaths.\> +- #Text in between hashtags designates the user's input that must occur before the next line can display.# + +## Wrangler syntax + +- Commands should follow an object verb order, such as ‘d1 create’. + +```sh +wrangler <object / noun> <verb> +``` + +- Subcommands should follow the main command with a space + +```sh +wrangler <command> <subcommand> <arg> --<option>` +``` + +## Wrangler \<command\> --help + +```sh +🧮 *Brief description of the product, the value it offers and how Wrangler can interact with it* + +🔧 *Command is currently in open beta / command is experimental (if relevant)* + +Commands: + wrangler <command> <subcommand> <arg> *Description of command* + +Options: + -<o (option shorthand)>, --<option (option name)> *Option description* [*data_type*] [default: *true/false*] + + +-------------------- +📣 *Announcement* +📃 To learn more, visit our documentation on *Product name*: https://developers.cloudflare.com/*productname* +-------------------- + +``` + +## Create success state with binding + +```sh +🌀 Creating ___ with title "___" +✨ Success. *Add details of success and what the user can now do* +📣 *Optional announcement* + +To start interacting with this ___ from a Worker, *If additional steps required, such as obtaining account ID from dash, add them here* \(then\) open your Worker’s config file and add the following binding configuration: + +[[array]] +binding = "<VARIABLE_NAME>" +name = "___" +id = "___" +``` + +## General success state + +```sh +🌀 *Action verb* *Object*. *details of what is currently happening if necessary* +🚧 *Updated additional details of the current status if necessary* +✨ Success. *Add details of and what the user can now do* +📣 *Announcement* + +*Description of what the next steps the user can take to be successful. If there are predictable happy paths following a success state, make those paths clear to the user here.* +``` + +## Command related error + +```sh +✘ ERROR *API error code if applicable*: *Concise description of what the error is*: + +Error details: +*Description of what caused the error* + +How to solve this error: +*Direction on how to resolve the error* + +wrangler *example of full command user tried to run* + +*Description of the command’s purpose* + +Positionals: + positional *Positional description*. [data-type] [required/optional] + +Options: + -o, --option *Option description* [data type] [default: true/false] + +If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose + +🪵 Logs were written to <filepath> + +``` + +## General error + +```sh +✘ ERROR *Error code if applicable*: *Concise description of what the error is* + +Error details: +*description of what caused the error* + +How to solve this error: +*direction on how to resolve the error* + +-------------------- +To learn more about ___, read our documentation at https://developers.cloudflare.com/*productname* + +If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose +-------------------- + +√ Would you like to report this error to Cloudflare? <y/n> + +#User inputs y or n# + +🪵 Logs were written to <filepath> + +``` + +## Y/N choice + +```sh +*choice description* <y/n> +#User inputs y or n# + +``` + +## Wrangler prompts + +### Written value prompt + +```sh +wrangler <command> +<prompt request> +<defaut_value> + +#User enters value then presses enter# + +<response detailing what task/s have been performed and with what values where applicable OR continue to next prompt> +``` + +### Multiple choice prompt + +```sh +Wrangler <command> +<prompt request> + ◉ <Choice 1> + ○ <Choice 2> + ○ <Choice 3> + + #User presses enter# + +<response detailing what task/s have been performed and with what values where applicable OR continue to next prompt> +``` diff --git a/cloudflare-workers-outline.png b/cloudflare-workers-outline.png new file mode 100644 index 0000000..b0f54d8 Binary files /dev/null and b/cloudflare-workers-outline.png differ diff --git a/codeowners.toml b/codeowners.toml new file mode 100644 index 0000000..9d6f4d8 --- /dev/null +++ b/codeowners.toml @@ -0,0 +1,28 @@ +# Codeowners Plus - Advanced Configuration +# See: https://github.com/multimediallc/codeowners-plus +# See: CODEOWNERS.md for full documentation + +# Directories to ignore (no ownership checks, reduces review noise) +ignore = [] + +# Show detailed file-to-owner mapping in PR comments +detailed_reviewers = true + +# Don't dismiss stale reviews when new changes are pushed +disable_smart_dismissal = true + +# Suppress warnings for intentionally unowned files (e.g. pnpm-lock.yaml) +suppress_unowned_warning = true + +[enforcement] +# The bot (@workers-devprod) approves PRs when all ownership rules are satisfied, +# unblocking the native GitHub CODEOWNERS requirement in branch protection. +# The check still runs for visibility (PR comments, review requests) but does not +# fail — enforcement is via the bot's CODEOWNERS approval only. +approval = true +fail_check = false + +[admin_bypass] +# Allow repo admins to bypass codeowner requirements in emergencies +# by submitting an approval review containing "Codeowners Bypass" text +enabled = true diff --git a/fixtures/additional-modules/package.json b/fixtures/additional-modules/package.json new file mode 100644 index 0000000..d809ac7 --- /dev/null +++ b/fixtures/additional-modules/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fixture/additional-modules", + "private": true, + "scripts": { + "build": "wrangler deploy --dry-run --outdir=dist", + "check:type": "tsc", + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./test/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/shared": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/additional-modules/src/common.cjs b/fixtures/additional-modules/src/common.cjs new file mode 100644 index 0000000..19658c1 --- /dev/null +++ b/fixtures/additional-modules/src/common.cjs @@ -0,0 +1 @@ +module.exports = "common"; diff --git a/fixtures/additional-modules/src/dep.ts b/fixtures/additional-modules/src/dep.ts new file mode 100644 index 0000000..cc0be2a --- /dev/null +++ b/fixtures/additional-modules/src/dep.ts @@ -0,0 +1 @@ +export default "bundled"; diff --git a/fixtures/additional-modules/src/dynamic.js b/fixtures/additional-modules/src/dynamic.js new file mode 100644 index 0000000..6c40343 --- /dev/null +++ b/fixtures/additional-modules/src/dynamic.js @@ -0,0 +1 @@ +export default "dynamic"; diff --git a/fixtures/additional-modules/src/example.sql b/fixtures/additional-modules/src/example.sql new file mode 100644 index 0000000..d9c6bf9 --- /dev/null +++ b/fixtures/additional-modules/src/example.sql @@ -0,0 +1 @@ +SELECT * FROM users; diff --git a/fixtures/additional-modules/src/index.ts b/fixtures/additional-modules/src/index.ts new file mode 100644 index 0000000..de1cc44 --- /dev/null +++ b/fixtures/additional-modules/src/index.ts @@ -0,0 +1,32 @@ +import common from "./common.cjs"; +import dep from "./dep"; +import sql from "./example.sql"; +import text from "./text.txt"; + +export default <ExportedHandler>{ + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/dep") { + return new Response(dep); + } + if (url.pathname === "/text") { + return new Response(text); + } + if (url.pathname === "/sql") { + return new Response(sql); + } + if (url.pathname === "/common") { + return new Response(common); + } + if (url.pathname === "/dynamic") { + return new Response((await import("./dynamic.js")).default); + } + if (url.pathname.startsWith("/lang/")) { + // Build the path dynamically to ensure esbuild doesn't inline the import. + const language = + "./lang/" + url.pathname.substring("/lang/".length) + ".js"; + return new Response((await import(language)).default.hello); + } + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/fixtures/additional-modules/src/lang/en.js b/fixtures/additional-modules/src/lang/en.js new file mode 100644 index 0000000..969f5b9 --- /dev/null +++ b/fixtures/additional-modules/src/lang/en.js @@ -0,0 +1 @@ +export default { hello: "hello" }; diff --git a/fixtures/additional-modules/src/lang/fr.js b/fixtures/additional-modules/src/lang/fr.js new file mode 100644 index 0000000..67e5320 --- /dev/null +++ b/fixtures/additional-modules/src/lang/fr.js @@ -0,0 +1 @@ +export default { hello: "bonjour" }; diff --git a/fixtures/additional-modules/src/text.d.ts b/fixtures/additional-modules/src/text.d.ts new file mode 100644 index 0000000..646ec46 --- /dev/null +++ b/fixtures/additional-modules/src/text.d.ts @@ -0,0 +1,9 @@ +declare module "*.txt" { + const value: string; + export default value; +} + +declare module "*.sql" { + const value: string; + export default value; +} diff --git a/fixtures/additional-modules/src/text.txt b/fixtures/additional-modules/src/text.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/fixtures/additional-modules/src/text.txt @@ -0,0 +1 @@ +test diff --git a/fixtures/additional-modules/test/index.test.ts b/fixtures/additional-modules/test/index.test.ts new file mode 100644 index 0000000..33495fd --- /dev/null +++ b/fixtures/additional-modules/test/index.test.ts @@ -0,0 +1,216 @@ +import childProcess from "node:child_process"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { removeDir } from "@fixture/shared/src/fs-helpers"; +import { afterAll, assert, beforeAll, describe, test, vi } from "vitest"; +import { unstable_startWorker } from "wrangler"; +import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived"; + +async function getTmpDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "wrangler-modules-")); +} + +type WranglerDev = Awaited<ReturnType<typeof unstable_startWorker>>; +function get(worker: WranglerDev, pathname: string) { + const url = `http://example.com${pathname}`; + // Disable Miniflare's pretty error page, so we can parse errors as JSON + return worker.fetch(url, { headers: { "MF-Disable-Pretty-Error": "true" } }); +} + +describe("find_additional_modules dev", () => { + let tmpDir: string; + let worker: WranglerDev; + + beforeAll(async () => { + // Copy over files to a temporary directory as we'll be modifying them + tmpDir = await getTmpDir(); + await fs.cp( + path.resolve(__dirname, "..", "src"), + path.join(tmpDir, "src"), + { recursive: true } + ); + await fs.cp( + path.resolve(__dirname, "..", "wrangler.jsonc"), + path.join(tmpDir, "wrangler.jsonc") + ); + + worker = await unstable_startWorker({ + config: path.join(tmpDir, "wrangler.jsonc"), + }); + }); + afterAll(async () => { + await worker.dispose(); + removeDir(tmpDir, { fireAndForget: true }); + }); + + test("supports bundled modules", async ({ expect }) => { + const res = await get(worker, "/dep"); + expect(await res.text()).toBe("bundled"); + }); + test("supports text modules", async ({ expect }) => { + const res = await get(worker, "/text"); + expect(await res.text()).toBe("test\n"); + }); + test("supports SQL modules", async ({ expect }) => { + const res = await get(worker, "/sql"); + expect(await res.text()).toBe("SELECT * FROM users;\n"); + }); + test("supports dynamic imports", async ({ expect }) => { + const res = await get(worker, "/dynamic"); + expect(await res.text()).toBe("dynamic"); + }); + test("supports commonjs lazy imports", async ({ expect }) => { + const res = await get(worker, "/common"); + expect(await res.text()).toBe("common"); + }); + test("supports variable dynamic imports", async ({ expect }) => { + const res = await get(worker, "/lang/en"); + expect(await res.text()).toBe("hello"); + }); + + test("watches additional modules", async ({ expect }) => { + const srcDir = path.join(tmpDir, "src"); + + // Update dynamically imported file + await fs.writeFile( + path.join(srcDir, "dynamic.js"), + 'export default "new dynamic";' + ); + await vi.waitFor(async () => { + const res = await get(worker, "/dynamic"); + assert.strictEqual(await res.text(), "new dynamic"); + }); + + // Delete dynamically imported file + await fs.rm(path.join(srcDir, "lang", "en.js")); + + await vi.waitFor(async () => { + await expect(get(worker, "/lang/en")).rejects.toThrow( + 'No such module "lang/en.js".' + ); + }); + + // Create new dynamically imported file in new directory + await fs.mkdir(path.join(srcDir, "lang", "en")); + await fs.writeFile( + path.join(srcDir, "lang", "en", "us.js"), + 'export default { hello: "hey" };' + ); + await vi.waitFor(async () => { + const res = await get(worker, "/lang/en/us"); + assert.strictEqual(await res.text(), "hey"); + }); + + // Update newly created file + await fs.writeFile( + path.join(srcDir, "lang", "en", "us.js"), + 'export default { hello: "bye" };' + ); + await vi.waitFor(async () => { + const res = await get(worker, "/lang/en/us"); + assert.strictEqual(await res.text(), "bye"); + }); + }); +}); + +function build(cwd: string, outDir: string) { + return childProcess.spawnSync( + process.execPath, + [wranglerEntryPath, "deploy", "--dry-run", `--outdir=${outDir}`], + { cwd } + ); +} + +describe("find_additional_modules deploy", () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = await getTmpDir(); + }); + afterAll(async () => await removeDir(tmpDir, { fireAndForget: true })); + + test("doesn't bundle additional modules", async ({ expect }) => { + const outDir = path.join(tmpDir, "out"); + const result = await build(path.resolve(__dirname, ".."), outDir); + expect(result.status).toBe(0); + + // Check additional modules marked external, but other dependencies bundled + const bundledEntryPath = path.join(outDir, "index.js"); + const bundledEntry = await fs.readFile(bundledEntryPath, "utf8"); + expect(bundledEntry).toMatchInlineSnapshot(` + "// src/index.ts + import common from "./common.cjs"; + + // src/dep.ts + var dep_default = "bundled"; + + // src/index.ts + import sql from "./example.sql"; + import text from "./text.txt"; + var index_default = { + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/dep") { + return new Response(dep_default); + } + if (url.pathname === "/text") { + return new Response(text); + } + if (url.pathname === "/sql") { + return new Response(sql); + } + if (url.pathname === "/common") { + return new Response(common); + } + if (url.pathname === "/dynamic") { + return new Response((await import("./dynamic.js")).default); + } + if (url.pathname.startsWith("/lang/")) { + const language = "./lang/" + url.pathname.substring("/lang/".length) + ".js"; + return new Response((await import(language)).default.hello); + } + return new Response("Not Found", { status: 404 }); + } + }; + export { + index_default as default + }; + //# sourceMappingURL=index.js.map + " + `); + + // Check additional modules included in output + expect(existsSync(path.join(outDir, "text.txt"))).toBe(true); + expect(existsSync(path.join(outDir, "dynamic.js"))).toBe(true); + expect(existsSync(path.join(outDir, "lang", "en.js"))).toBe(true); + expect(existsSync(path.join(outDir, "lang", "fr.js"))).toBe(true); + }); + + test("fails with service worker entrypoint", async ({ expect }) => { + // Write basic service worker with `find_additional_modules` enabled + const serviceWorkerDir = path.join(tmpDir, "service-worker"); + await fs.mkdir(serviceWorkerDir, { recursive: true }); + await fs.writeFile( + path.join(serviceWorkerDir, "index.js"), + "addEventListener('fetch', (e) => e.respondWith(new Response()))" + ); + await fs.writeFile( + path.join(serviceWorkerDir, "wrangler.toml"), + [ + 'name="service-worker-test"', + 'main = "index.js"', + 'compatibility_date = "2023-08-01"', + "find_additional_modules = true", + ].join("\n") + ); + + // Try build, and check fails + const serviceWorkerOutDir = path.join(tmpDir, "service-worker-out"); + const result = await build(serviceWorkerDir, serviceWorkerOutDir); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain( + "`find_additional_modules` can only be used with an ES module entrypoint." + ); + }); +}); diff --git a/fixtures/additional-modules/test/tsconfig.json b/fixtures/additional-modules/test/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/additional-modules/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/additional-modules/tsconfig.json b/fixtures/additional-modules/tsconfig.json new file mode 100644 index 0000000..873892f --- /dev/null +++ b/fixtures/additional-modules/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["esnext"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "types": ["@cloudflare/workers-types/experimental"], + "allowJs": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/fixtures/additional-modules/turbo.json b/fixtures/additional-modules/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/additional-modules/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/additional-modules/vitest.config.mts b/fixtures/additional-modules/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/additional-modules/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/additional-modules/wrangler.jsonc b/fixtures/additional-modules/wrangler.jsonc new file mode 100644 index 0000000..6eee83e --- /dev/null +++ b/fixtures/additional-modules/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "name": "additional-modules", + "main": "src/index.ts", + "compatibility_date": "2023-08-01", + "find_additional_modules": true, + "rules": [ + { + "type": "CommonJS", + "globs": ["**/*.cjs"], + }, + { + "type": "ESModule", + "globs": ["**/*.js"], + }, + ], +} diff --git a/fixtures/browser-run/package.json b/fixtures/browser-run/package.json new file mode 100644 index 0000000..638083b --- /dev/null +++ b/fixtures/browser-run/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/browser-run", + "private": true, + "scripts": { + "cf-typegen": "wrangler types --no-include-runtime", + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "test": "vitest", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/playwright": "^1.0.0", + "@cloudflare/puppeteer": "^1.0.4", + "@cloudflare/vitest-pool-workers": "workspace:*", + "@types/node": "catalog:default", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/browser-run/src/index.ts b/fixtures/browser-run/src/index.ts new file mode 100644 index 0000000..318c542 --- /dev/null +++ b/fixtures/browser-run/src/index.ts @@ -0,0 +1,15 @@ +import playwrightWorker from "./playwright"; +import puppeteerWorker from "./puppeteer"; + +export default { + async fetch(request, env): Promise<Response> { + const { searchParams } = new URL(request.url); + let lib = searchParams.get("lib"); + + if (lib === "playwright") { + return playwrightWorker.fetch(request, env); + } else { + return puppeteerWorker.fetch(request, env); + } + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/browser-run/src/playwright.ts b/fixtures/browser-run/src/playwright.ts new file mode 100644 index 0000000..c1ac1fc --- /dev/null +++ b/fixtures/browser-run/src/playwright.ts @@ -0,0 +1,74 @@ +import playwright from "@cloudflare/playwright"; + +export default { + async fetch(request, env): Promise<Response> { + const { searchParams } = new URL(request.url); + let url = searchParams.get("url"); + let action = searchParams.get("action"); + if (url) { + url = new URL(url).toString(); // normalize + switch (action) { + case "select": { + await using browser = await playwright.launch(env.MYBROWSER); + const page = await browser.newPage(); + await page.goto(url); + const h1Text = await page.locator("h1").textContent(); + return new Response(h1Text); + } + + case "alter": { + await using browser = await playwright.launch(env.MYBROWSER); + const page = await browser.newPage(); + + await page.goto(url); // change to your target URL + + await page + .locator("p") + .first() + .evaluate((paragraph) => { + paragraph.textContent = "New paragraph text set by Playwright!"; + }); + + const pText = await page.locator("p").first().textContent(); + return new Response(pText); + } + + case "disconnect": { + const { sessionId } = await playwright.acquire(env.MYBROWSER); + const browser = await playwright.connect(env.MYBROWSER, sessionId); + // closing a browser obtained with playwright.connect actually disconnects + // (it doesn't close the process) + await browser.close(); + const sessionInfo = await playwright + .sessions(env.MYBROWSER) + .then((sessions) => + sessions.find((s) => s.sessionId === sessionId) + ); + return new Response( + sessionInfo.connectionId + ? "Browser not disconnected" + : "Browser disconnected" + ); + } + } + + let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" }); + if (img === null) { + await using browser = await playwright.launch(env.MYBROWSER); + const page = await browser.newPage(); + await page.goto(url); + img = (await page.screenshot()) as Buffer; + await env.BROWSER_KV_DEMO.put(url, img, { + expirationTtl: 60 * 60 * 24, + }); + } + return new Response(img, { + headers: { + "content-type": "image/jpeg", + }, + }); + } else { + return new Response("Please add an ?url=https://example.com/ parameter"); + } + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/browser-run/src/puppeteer.ts b/fixtures/browser-run/src/puppeteer.ts new file mode 100644 index 0000000..23ce196 --- /dev/null +++ b/fixtures/browser-run/src/puppeteer.ts @@ -0,0 +1,73 @@ +import puppeteer from "@cloudflare/puppeteer"; + +export default { + async fetch(request, env): Promise<Response> { + const { searchParams } = new URL(request.url); + let url = searchParams.get("url"); + let action = searchParams.get("action"); + if (url) { + url = new URL(url).toString(); // normalize + switch (action) { + case "select": { + const browser = await puppeteer.launch(env.MYBROWSER); + const page = await browser.newPage(); + await page.goto(url); + const h1Text = await page.$eval("h1", (el) => el.textContent.trim()); + return new Response(h1Text); + } + + case "alter": { + const browser = await puppeteer.launch(env.MYBROWSER); + const page = await browser.newPage(); + + await page.goto(url); // change to your target URL + + await page.evaluate(() => { + const paragraph = document.querySelector("p"); + if (paragraph) { + paragraph.textContent = "New paragraph text set by Puppeteer!"; + } + }); + + const pText = await page.$eval("p", (el) => el.textContent.trim()); + return new Response(pText); + } + + case "disconnect": { + const browser = await puppeteer.launch(env.MYBROWSER); + const sessionId = browser.sessionId(); + await browser.disconnect(); + const sessionInfo = await puppeteer + .sessions(env.MYBROWSER) + .then((sessions) => + sessions.find((s) => s.sessionId === sessionId) + ); + return new Response( + sessionInfo.connectionId + ? "Browser not disconnected" + : "Browser disconnected" + ); + } + } + + let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" }); + if (img === null) { + const browser = await puppeteer.launch(env.MYBROWSER); + const page = await browser.newPage(); + await page.goto(url); + img = (await page.screenshot()) as Buffer; + await env.BROWSER_KV_DEMO.put(url, img, { + expirationTtl: 60 * 60 * 24, + }); + await browser.close(); + } + return new Response(img, { + headers: { + "content-type": "image/jpeg", + }, + }); + } else { + return new Response("Please add an ?url=https://example.com/ parameter"); + } + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/browser-run/test/index.spec.ts b/fixtures/browser-run/test/index.spec.ts new file mode 100644 index 0000000..baa61cd --- /dev/null +++ b/fixtures/browser-run/test/index.spec.ts @@ -0,0 +1,97 @@ +// test/index.spec.ts +import { rm } from "node:fs/promises"; +import { resolve } from "path"; +import { afterAll, beforeAll, describe, it, TestOptions } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +const BROWSER_RENDERING_RETRY = { + retry: { + condition: /Chrome readiness probe .* timed out|Test timed out/i, + count: 3, + delay: 1_000, + }, +} satisfies TestOptions; + +describe.sequential("Local Browser", () => { + let ip: string, + port: number, + stop: (() => Promise<unknown>) | undefined, + getOutput: () => string; + + beforeAll(async () => { + // delete previous run contents because of persistence + await rm(resolve(__dirname, "..") + "/.wrangler", { + force: true, + recursive: true, + }); + ({ ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--local", "--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + async function fetchText(url: string) { + const response = await fetch(url, { + headers: { + "MF-Disable-Pretty-Error": "1", + }, + }); + const text = await response.text(); + + return text; + } + + for (const lib of ["puppeteer", "playwright"]) { + describe(`using @cloudflare/${lib}`, () => { + it("Doesn't run a browser, just testing that the worker is running!", async ({ + expect, + }) => { + await expect( + fetchText(`http://${ip}:${port}/?lib=${lib}`) + ).resolves.toEqual("Please add an ?url=https://example.com/ parameter"); + }); + + it( + "Run a browser, and check h1 text content", + BROWSER_RENDERING_RETRY, + async ({ expect }) => { + await expect( + fetchText( + `http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=select` + ) + ).resolves.toEqual("Example Domain"); + } + ); + + it( + "Run a browser, and check p text content", + BROWSER_RENDERING_RETRY, + async ({ expect }) => { + await expect( + fetchText( + `http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=alter` + ) + ).resolves.toEqual( + `New paragraph text set by ${lib === "playwright" ? "Playwright" : "Puppeteer"}!` + ); + } + ); + + it( + "Disconnect a browser, and check its session connection status", + BROWSER_RENDERING_RETRY, + async ({ expect }) => { + await expect( + fetchText( + `http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=disconnect` + ) + ).resolves.toEqual(`Browser disconnected`); + } + ); + }); + } +}); diff --git a/fixtures/browser-run/test/tsconfig.json b/fixtures/browser-run/test/tsconfig.json new file mode 100644 index 0000000..3562d99 --- /dev/null +++ b/fixtures/browser-run/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["./**/*.ts", "../worker-configuration.d.ts"] +} diff --git a/fixtures/browser-run/tsconfig.json b/fixtures/browser-run/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/browser-run/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/browser-run/vitest.config.mts b/fixtures/browser-run/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/browser-run/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/browser-run/worker-configuration.d.ts b/fixtures/browser-run/worker-configuration.d.ts new file mode 100644 index 0000000..76843f8 --- /dev/null +++ b/fixtures/browser-run/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --no-include-runtime` (hash: 8df6fc5ccb1f778e4b6799ea0fe8d9e6) +interface __BaseEnv_Env { + BROWSER_KV_DEMO: KVNamespace; + MYBROWSER: Fetcher; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/browser-run/wrangler.jsonc b/fixtures/browser-run/wrangler.jsonc new file mode 100644 index 0000000..2b089a0 --- /dev/null +++ b/fixtures/browser-run/wrangler.jsonc @@ -0,0 +1,24 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "browser-rendering", + "main": "src/index.ts", + "compatibility_date": "2025-12-01", + "observability": { + "enabled": true, + }, + "compatibility_flags": ["nodejs_compat"], + "browser": { + "binding": "MYBROWSER", + }, + "kv_namespaces": [ + { + "binding": "BROWSER_KV_DEMO", + "id": "8a5482302d2e4bcb8186416eea7e38b1", + "preview_id": "8a5482302d2e4bcb8186416eea7e38b1", + }, + ], +} diff --git a/fixtures/container-app/.gitignore b/fixtures/container-app/.gitignore new file mode 100644 index 0000000..c6e1d1c --- /dev/null +++ b/fixtures/container-app/.gitignore @@ -0,0 +1,168 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars* +!.dev.vars.example +.env* +!.env.example +.wrangler/ +worker-configuration.d.ts diff --git a/fixtures/container-app/Dockerfile b/fixtures/container-app/Dockerfile new file mode 100644 index 0000000..eaeb93f --- /dev/null +++ b/fixtures/container-app/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app", "version": "1.0.0", "dependencies": {"ws": "^8.0.0"}}' > package.json +RUN npm install + +COPY ./container/simple-node-app.js app.js +EXPOSE 8080 diff --git a/fixtures/container-app/README.md b/fixtures/container-app/README.md new file mode 100644 index 0000000..d0c6fd4 --- /dev/null +++ b/fixtures/container-app/README.md @@ -0,0 +1,3 @@ +# HTTP fetch to a container + +This example shows a simple container setup where a DO passes requests through to a node server. diff --git a/fixtures/container-app/container/simple-node-app.js b/fixtures/container-app/container/simple-node-app.js new file mode 100644 index 0000000..fd5e60e --- /dev/null +++ b/fixtures/container-app/container/simple-node-app.js @@ -0,0 +1,49 @@ +const { createServer } = require("http"); + +const webSocketEnabled = process.env.WS_ENABLED === "true"; + +// Create HTTP server +const server = createServer(function (req, res) { + if (req.url === "/ws") { + // WebSocket upgrade will be handled by the WebSocket server + return; + } + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("Hello World! Have an env var! " + process.env.MESSAGE); + res.end(); +}); + +// Check if WebSocket functionality is enabled +if (webSocketEnabled) { + const WebSocket = require("ws"); + + // Create WebSocket server + const wss = new WebSocket.Server({ + server: server, + path: "/ws", + }); + + wss.on("connection", function connection(ws) { + console.log("WebSocket connection established"); + + ws.on("message", function message(data) { + console.log("Received:", data.toString()); + // Echo the message back with prefix + ws.send("Echo: " + data.toString()); + }); + + ws.on("close", function close() { + console.log("WebSocket connection closed"); + }); + + ws.on("error", console.error); + }); +} + +server.listen(8080, function () { + console.log("Server listening on port 8080"); + if (webSocketEnabled) { + console.log("WebSocket support enabled"); + } +}); diff --git a/fixtures/container-app/package.json b/fixtures/container-app/package.json new file mode 100644 index 0000000..b93d4aa --- /dev/null +++ b/fixtures/container-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/container-app", + "private": true, + "scripts": { + "cf-typegen": "wrangler types --no-include-runtime", + "container:build": "wrangler containers build ./ -t container-fixture", + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "dev:registry": "wrangler dev -c ./wrangler.registry.jsonc", + "start": "wrangler dev", + "start:registry": "wrangler dev -c ./wrangler.registry.jsonc" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "ts-dedent": "^2.2.0", + "typescript": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/container-app/src/index.ts b/fixtures/container-app/src/index.ts new file mode 100644 index 0000000..ca55dd9 --- /dev/null +++ b/fixtures/container-app/src/index.ts @@ -0,0 +1,68 @@ +import { DurableObject } from "cloudflare:workers"; + +export class FixtureTestContainer extends DurableObject<Env> { + container: globalThis.Container; + monitor?: Promise<unknown>; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.container = ctx.container; + } + + async fetch(req: Request) { + const path = new URL(req.url).pathname; + switch (path) { + case "/status": + return new Response(JSON.stringify(this.container.running)); + + case "/destroy": + if (!this.container.running) { + throw new Error("Container is not running."); + } + await this.container.destroy(); + return new Response(JSON.stringify(this.container.running)); + + case "/start": + this.container.start({ + entrypoint: ["node", "app.js"], + env: { MESSAGE: "I'm an env var!" }, + enableInternet: false, + }); + // this doesn't instantly start, so we will need to poll /fetch + return new Response("Container create request sent..."); + + case "/fetch": + const res = await this.container + .getTcpPort(8080) + // actual request doesn't matter + .fetch("http://foo/bar/baz", { method: "POST", body: "hello" }); + return new Response(await res.text()); + + case "/destroy-with-monitor": + const monitor = this.container.monitor(); + await this.container.destroy(); + await monitor; + return new Response("Container destroyed with monitor."); + + default: + return new Response("Hi from Container DO"); + } + } +} + +export default { + async fetch(request, env, ctx: ExecutionContext): Promise<Response> { + const url = new URL(request.url); + if (url.pathname === "/second") { + // This is a second Durable Object that can be used to test multiple DOs + const id = + ctx.exports.FixtureTestContainer.idFromName("second-container"); + const stub = ctx.exports.FixtureTestContainer.get(id); + const query = url.searchParams.get("req"); + return stub.fetch("http://example.com/" + query); + } + const id = ctx.exports.FixtureTestContainer.idFromName("container"); + const stub = ctx.exports.FixtureTestContainer.get(id); + return stub.fetch(request); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/container-app/tsconfig.json b/fixtures/container-app/tsconfig.json new file mode 100644 index 0000000..cee17af --- /dev/null +++ b/fixtures/container-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/container-app/wrangler.jsonc b/fixtures/container-app/wrangler.jsonc new file mode 100644 index 0000000..37e82de --- /dev/null +++ b/fixtures/container-app/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "container-app", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "compatibility_flags": ["enable_ctx_exports"], + "containers": [ + { + "image": "./Dockerfile", + "class_name": "FixtureTestContainer", + "name": "container", + "max_instances": 2, + }, + ], + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainer"], + }, + ], +} diff --git a/fixtures/container-app/wrangler.registry.jsonc b/fixtures/container-app/wrangler.registry.jsonc new file mode 100644 index 0000000..ac2e3a8 --- /dev/null +++ b/fixtures/container-app/wrangler.registry.jsonc @@ -0,0 +1,28 @@ +{ + "name": "container-app-from-registry", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + // This is the same container as built by the dockerfile except it is pulled from the registry + "image": "registry.cloudflare.com/8d783f274e1f82dc46744c297b015a2f/ci-container-dont-delete:latest", + "class_name": "FixtureTestContainer", + "name": "container", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "FixtureTestContainer", + "name": "CONTAINER", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainer"], + }, + ], +} diff --git a/fixtures/create-test-harness-example/.gitignore b/fixtures/create-test-harness-example/.gitignore new file mode 100644 index 0000000..aaa9103 --- /dev/null +++ b/fixtures/create-test-harness-example/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/fixtures/create-test-harness-example/README.md b/fixtures/create-test-harness-example/README.md new file mode 100644 index 0000000..ba97936 --- /dev/null +++ b/fixtures/create-test-harness-example/README.md @@ -0,0 +1,66 @@ +# Integration testing Workers with `createTestHarness()` + +This fixture is the complete runnable example linked from the `createTestHarness()` docs. It shows how to test a realistic multi-Worker app's production build output from different test runners. + +For the testing patterns demonstrated here, refer to the [`createTestHarness()` examples guide](https://developers.cloudflare.com/workers/testing/test-harness/examples/). + +## Example app + +The app has three Workers: + +| Worker | Route | Role | +| -------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `web-worker` | `example.com/*` | Handles user-facing routes, calls the API Worker over a service binding, and uses Browser Rendering to generate previews. | +| `api-worker` | `api.example.com/v1/*` | Fetches upstream user data, caches it in KV, and stores scheduled reports in D1. | +| `mock-browser` | none | Test-only Worker used to replace the Web Worker's Browser Rendering binding. | + +## What this fixture covers + +- Setup [Vitest](https://vitest.dev/) against Workers developed with Wrangler. +- Setup [Playwright](https://playwright.dev/) against Workers built by the Cloudflare Vite plugin. +- Route dispatch across multiple Workers. +- Direct Worker calls with `server.getWorker(name)`. +- D1 migration setup with `worker.applyD1Migrations()`. +- Scheduled handler dispatch. +- Test-only binding overrides for platform bindings. +- Calling a Worker's default export with `server.getWorker(name).getExport()`. +- Outbound request mocking with MSW. +- Local storage reset between tests. +- Debug output with `server.debug()`. +- Runtime log assertions with `server.getLogs()` and `server.clearLogs()`. + +## Run this example + +To build the packages, run: + +```sh +pnpm build --filter @fixture/create-test-harness-example +``` + +Then run the tests with: + +```sh +pnpm --filter @fixture/create-test-harness-example test:vitest +pnpm --filter @fixture/create-test-harness-example test:playwright +``` + +## Files + +| File | Purpose | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| [`tests/vitest.test.ts`](tests/vitest.test.ts) | Example for testing with Vitest. | +| [`tests/playwright.test.ts`](tests/playwright.test.ts) | Example for testing with Playwright. | +| [`vite.config.ts`](vite.config.ts) | Builds Vite-generated Worker configs. | +| [`workers/web/index.ts`](workers/web/index.ts) | User-facing Worker with service and Browser Rendering binding logic. | +| [`workers/web/wrangler.jsonc`](workers/web/wrangler.jsonc) | Web Worker config. | +| [`workers/web/worker-configuration.d.ts`](workers/web/worker-configuration.d.ts) | Generated Web Worker types. | +| [`workers/api/index.ts`](workers/api/index.ts) | API Worker with KV, D1, and scheduled job logic. | +| [`workers/api/wrangler.jsonc`](workers/api/wrangler.jsonc) | API Worker config. | +| [`workers/api/worker-configuration.d.ts`](workers/api/worker-configuration.d.ts) | Generated API Worker types. | +| [`workers/mock-browser/index.ts`](workers/mock-browser/index.ts) | Test-only mock Worker for the Browser Rendering binding. | +| [`workers/mock-browser/wrangler.jsonc`](workers/mock-browser/wrangler.jsonc) | Mock Browser Worker config. | + +## Related docs + +- [`createTestHarness()` guide](https://developers.cloudflare.com/workers/testing/test-harness/) +- [`createTestHarness()` API reference](https://developers.cloudflare.com/workers/wrangler/api/#createtestharness) diff --git a/fixtures/create-test-harness-example/package.json b/fixtures/create-test-harness-example/package.json new file mode 100644 index 0000000..42260df --- /dev/null +++ b/fixtures/create-test-harness-example/package.json @@ -0,0 +1,35 @@ +{ + "name": "@fixture/create-test-harness-example", + "private": true, + "description": "Integration tests with the createTestHarness() API", + "type": "module", + "scripts": { + "cf-typegen": "pnpm typegen:web && pnpm typegen:api", + "typegen:web": "wrangler types ./workers/web/worker-configuration.d.ts -c ./workers/web/wrangler.jsonc -c ./workers/api/wrangler.jsonc --env-interface WebEnv --no-include-runtime", + "typegen:api": "wrangler types ./workers/api/worker-configuration.d.ts -c ./workers/api/wrangler.jsonc --env-interface ApiEnv --no-include-runtime", + "check:type": "tsc", + "build": "vite build", + "playwright:install": "pnpm playwright install chromium", + "pretest:ci": "pnpm playwright:install", + "pretest:playwright": "pnpm playwright:install", + "test:playwright": "playwright test", + "test:vitest": "vitest run", + "test:ci": "vitest run && playwright test", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@playwright/test": "catalog:default", + "@types/node": "catalog:default", + "msw": "catalog:default", + "typescript": "catalog:default", + "vite": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/fixtures/create-test-harness-example/playwright.config.ts b/fixtures/create-test-harness-example/playwright.config.ts new file mode 100644 index 0000000..68eaf0a --- /dev/null +++ b/fixtures/create-test-harness-example/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "playwright.test.ts", + use: { + browserName: "chromium", + }, +}); diff --git a/fixtures/create-test-harness-example/tests/playwright.test.ts b/fixtures/create-test-harness-example/tests/playwright.test.ts new file mode 100644 index 0000000..bd07bcc --- /dev/null +++ b/fixtures/create-test-harness-example/tests/playwright.test.ts @@ -0,0 +1,107 @@ +import { test as base, expect } from "@playwright/test"; +import { http, HttpResponse } from "msw"; +import { setupServer, type SetupServerApi } from "msw/node"; +import { createTestHarness, type TestHarness } from "wrangler"; + +type TestFixtures = { + reset: void; +}; + +type WorkerFixtures = { + network: SetupServerApi; + server: TestHarness; +}; + +const test = base.extend<TestFixtures, WorkerFixtures>({ + network: [ + async ({}, use) => { + const network = setupServer(); + network.listen({ onUnhandledRequest: "error" }); + + await use(network); + + network.close(); + }, + { scope: "worker" }, + ], + + server: [ + async ({}, use) => { + const server = createTestHarness({ + workers: [ + // Playwright tests run against the Vite build output. + { configPath: "./dist/web_worker/wrangler.json" }, + { configPath: "./dist/api_worker/wrangler.json" }, + ], + }); + + await server.listen(); + + await use(server); + + await server.close(); + }, + { scope: "worker" }, + ], + + baseURL: async ({ server }, use) => { + const { url } = await server.listen(); + await use(url.href); + }, + + reset: [ + async ({ network, server }, use, testInfo) => { + await server.getWorker("api-worker").applyD1Migrations("DATABASE"); + + await use(); + + if (testInfo.status !== testInfo.expectedStatus) { + server.debug(); + } + + network.resetHandlers(); + await server.reset(); + }, + { auto: true }, + ], +}); + +test.describe("createTestHarness: Playwright setup", () => { + test("renders a user profile", async ({ page, network }) => { + network.use( + http.get("http://identity.example.com/profile/:id", ({ params }) => { + return HttpResponse.json({ id: params.id, name: "Ada" }); + }) + ); + + await page.goto("/users/123"); + + await expect(page.getByText("Profile: Ada")).toBeVisible(); + }); + + test("renders a report generated by a scheduled handler", async ({ + page, + network, + server, + }) => { + network.use( + http.get("http://identity.example.com/profile/:id", ({ params }) => { + return HttpResponse.json({ id: params.id, name: "Ada" }); + }) + ); + + const apiWorker = server.getWorker("api-worker"); + await apiWorker.fetch("/v1/users/123"); + await apiWorker.fetch("/v1/users/456"); + await apiWorker.scheduled({ + cron: "0 0 * * *", + scheduledTime: new Date("2026-05-29T00:00:00.000Z"), + }); + + await page.goto("/reports/2026-05-29"); + + await expect( + page.getByText("Daily report (2026-05-29): active users 123, 456") + ).toBeVisible(); + }); +}); diff --git a/fixtures/create-test-harness-example/tests/tsconfig.json b/fixtures/create-test-harness-example/tests/tsconfig.json new file mode 100644 index 0000000..7ba7997 --- /dev/null +++ b/fixtures/create-test-harness-example/tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "noEmit": true, + "types": ["@cloudflare/workers-types", "node"] + }, + "include": [ + "../playwright.config.ts", + "../workers/*/worker-configuration.d.ts", + "**/*.ts" + ], + "exclude": [] +} diff --git a/fixtures/create-test-harness-example/tests/vitest.test.ts b/fixtures/create-test-harness-example/tests/vitest.test.ts new file mode 100644 index 0000000..4829436 --- /dev/null +++ b/fixtures/create-test-harness-example/tests/vitest.test.ts @@ -0,0 +1,160 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + test, +} from "vitest"; +import { createTestHarness } from "wrangler"; + +// Point each worker to the Wrangler config you want to test. +const server = createTestHarness({ + workers: [ + { + configPath: "./workers/web/wrangler.jsonc", + bindingOverrides: { BROWSER: "mock-browser" }, + }, + { configPath: "./workers/api/wrangler.jsonc" }, + { configPath: "./workers/mock-browser/wrangler.jsonc" }, + ], +}); + +// server.getWorker() would return the first Worker, but naming it makes it explicit. +const webWorker = server.getWorker< + WebEnv, + typeof import("../workers/web/index") +>("web-worker"); +const apiWorker = server.getWorker< + ApiEnv, + typeof import("../workers/api/index") +>("api-worker"); +const mockBrowserWorker = server.getWorker< + unknown, + typeof import("../workers/mock-browser/index") +>("mock-browser"); + +// Workers started by createTestHarness route outbound fetches to globalThis.fetch(). +// You can use libraries like MSW to intercept those requests. +const network = setupServer(); + +describe("createTestHarness: Vitest setup", () => { + beforeAll(async () => { + network.listen({ onUnhandledRequest: "error" }); + await server.listen(); + }); + + beforeEach(async () => { + await apiWorker.applyD1Migrations("DATABASE"); + }); + + afterAll(async () => { + network.close(); + await server.close(); + }); + + afterEach(async () => { + // Keep tests isolated while reusing the same running server. + network.resetHandlers(); + await server.reset(); + }); + + test("fetches the primary Worker with a relative URL", async ({ expect }) => { + // Relative URLs are dispatched to the primary Worker (the first one listed). + const response = await server.fetch("/"); + expect(await response.text()).toBe("Hello World"); + }); + + test("mocks outbound requests", async ({ expect }) => { + network.use( + http.get("http://identity.example.com/profile/:id", ({ params }) => { + return HttpResponse.json({ id: params.id, name: "Ada" }); + }) + ); + + const userResponse = await apiWorker.fetch("/v1/users/123"); + expect(await userResponse.json()).toEqual({ + id: "123", + name: "Ada", + }); + }); + + test("overrides a platform binding with a mock Worker", async ({ + expect, + }) => { + const apiEnv = await apiWorker.getEnv(); + await apiEnv.DATABASE.prepare( + "INSERT INTO daily_reports (date, user_ids) VALUES (?, ?)" + ) + .bind("2026-05-29", JSON.stringify(["123", "456"])) + .run(); + + const stubPng = Uint8Array.from([ + 137, 80, 78, 71, 13, 10, 26, 10, 109, 111, 99, 107, + ]); + const mockBrowser = await mockBrowserWorker.getExport(); + await mockBrowser.setScreenshot(Array.from(stubPng)); + + const response = await webWorker.fetch("/reports/2026-05-29.png"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(await response.bytes()).toEqual(stubPng); + }); + + test("dispatches requests using configured routes", async ({ expect }) => { + network.use( + http.get("http://identity.example.com/profile/:id", ({ params }) => { + return HttpResponse.json({ id: params.id, name: "Ada" }); + }) + ); + + // server.fetch() matches requests to workers based on routes. + const apiResponse = await server.fetch( + "http://api.example.com/v1/users/123" + ); + expect(await apiResponse.json()).toEqual({ + id: "123", + name: "Ada", + }); + + const webResponse = await server.fetch("http://example.com/users/123"); + expect(await webResponse.text()).toBe("Profile: Ada"); + }); + + test("runs scheduled jobs and stores the result", async ({ expect }) => { + network.use( + http.get("http://identity.example.com/profile/:id", ({ params }) => { + return HttpResponse.json({ id: params.id, name: "Ada" }); + }) + ); + + // Seed user data that the scheduled job will read to generate a report. + await apiWorker.fetch("/v1/users/123"); + await apiWorker.fetch("/v1/users/456"); + + const initialResponse = await webWorker.fetch("/reports/2026-05-29"); + expect(initialResponse.status).toBe(404); + expect(await initialResponse.text()).toBe("No report"); + + server.clearLogs(); + + expect( + await apiWorker.scheduled({ + cron: "0 0 * * *", + scheduledTime: new Date("2026-05-29T00:00:00.000Z"), + }) + ).toEqual({ outcome: "ok", noRetry: false }); + expect(server.getLogs()).toContainEqual( + expect.objectContaining({ + level: "info", + message: "Generated daily report for 2026-05-29", + }) + ); + + const webResponse = await webWorker.fetch("/reports/2026-05-29"); + expect(await webResponse.text()).toBe( + "Daily report (2026-05-29): active users 123, 456" + ); + }); +}); diff --git a/fixtures/create-test-harness-example/tsconfig.json b/fixtures/create-test-harness-example/tsconfig.json new file mode 100644 index 0000000..e8e338a --- /dev/null +++ b/fixtures/create-test-harness-example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "target": "esnext", + "strict": true, + "noEmit": true, + "types": ["@cloudflare/workers-types", "node"], + "lib": ["esnext"], + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/create-test-harness-example/turbo.json b/fixtures/create-test-harness-example/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/create-test-harness-example/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/create-test-harness-example/vite.config.ts b/fixtures/create-test-harness-example/vite.config.ts new file mode 100644 index 0000000..22edff0 --- /dev/null +++ b/fixtures/create-test-harness-example/vite.config.ts @@ -0,0 +1,16 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "./workers/web/wrangler.jsonc", + auxiliaryWorkers: [ + { configPath: "./workers/api/wrangler.jsonc" }, + { configPath: "./workers/mock-browser/wrangler.jsonc" }, + ], + inspectorPort: false, + persistState: false, + }), + ], +}); diff --git a/fixtures/create-test-harness-example/vitest.config.mts b/fixtures/create-test-harness-example/vitest.config.mts new file mode 100644 index 0000000..61bbfb0 --- /dev/null +++ b/fixtures/create-test-harness-example/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + include: ["tests/vitest.test.ts"], + }, + }) +); diff --git a/fixtures/create-test-harness-example/workers/api/index.ts b/fixtures/create-test-harness-example/workers/api/index.ts new file mode 100644 index 0000000..3a2e8f6 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/api/index.ts @@ -0,0 +1,97 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +type Env = { + STORE: KVNamespace; + DATABASE: D1Database; +}; + +type User = { + id: string; + name: string; +}; + +/** + * API Worker: fetches users from an upstream service, caches them in KV, and generates daily reports on a schedule. + */ +export default class ApiWorker extends WorkerEntrypoint<Env> { + async fetch(request: Request) { + const url = new URL(request.url); + const userPathPrefix = "/v1/users/"; + + if (url.pathname.startsWith(userPathPrefix)) { + const userId = url.pathname.slice(userPathPrefix.length); + return Response.json(await this.getUser(userId)); + } + + const reportPathPrefix = "/v1/reports/"; + + if (url.pathname.startsWith(reportPathPrefix)) { + const date = url.pathname.slice(reportPathPrefix.length); + const report = await this.getDailyReport(date); + + if (report === null) { + return Response.json({ error: "No report" }, { status: 404 }); + } + + return Response.json(report); + } + + return new Response("Not Found", { status: 404 }); + } + + async scheduled(event: ScheduledController) { + if (event.cron !== "0 0 * * *") { + throw new Error(`Unexpected cron: ${event.cron}`); + } + + const date = new Date(event.scheduledTime).toISOString().slice(0, 10); + const list = await this.env.STORE.list({ prefix: "user/" }); + const userIds = list.keys.map((key) => key.name.slice("user/".length)); + + await this.env.DATABASE.prepare( + "INSERT OR REPLACE INTO daily_reports (date, user_ids) VALUES (?, ?)" + ) + .bind(date, JSON.stringify(userIds)) + .run(); + console.info(`Generated daily report for ${date}`); + + for (const key of list.keys) { + await this.env.STORE.delete(key.name); + } + } + + async getUser(userId: string): Promise<User> { + const key = `user/${userId}`; + const cachedUser = await this.env.STORE.get<User>(key, { + type: "json", + }); + + if (cachedUser !== null) { + return cachedUser; + } + + const upstreamResponse = await fetch( + `http://identity.example.com/profile/${userId}` + ); + const user = await upstreamResponse.json<User>(); + + await this.env.STORE.put(key, JSON.stringify(user)); + + return user; + } + + async getDailyReport(date: string) { + const report = await this.env.DATABASE.prepare( + "SELECT user_ids FROM daily_reports WHERE date = ?" + ) + .bind(date) + .first<{ user_ids: string }>(); + + if (report === null) { + return null; + } + + const userIds: string[] = JSON.parse(report.user_ids); + return userIds; + } +} diff --git a/fixtures/create-test-harness-example/workers/api/migrations/0001_reports.sql b/fixtures/create-test-harness-example/workers/api/migrations/0001_reports.sql new file mode 100644 index 0000000..7e3c033 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/api/migrations/0001_reports.sql @@ -0,0 +1,4 @@ +CREATE TABLE daily_reports ( + date TEXT PRIMARY KEY, + user_ids TEXT NOT NULL +); diff --git a/fixtures/create-test-harness-example/workers/api/worker-configuration.d.ts b/fixtures/create-test-harness-example/workers/api/worker-configuration.d.ts new file mode 100644 index 0000000..771e0c1 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/api/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=./workers/api/wrangler.jsonc --include-runtime=false --env-interface=ApiEnv ./workers/api/worker-configuration.d.ts` (hash: 14b9b34fa5935bf62d0e0be93efbdb85) +interface __BaseEnv_ApiEnv { + STORE: KVNamespace; + DATABASE: D1Database; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_ApiEnv {} +} +interface ApiEnv extends __BaseEnv_ApiEnv {} diff --git a/fixtures/create-test-harness-example/workers/api/wrangler.jsonc b/fixtures/create-test-harness-example/workers/api/wrangler.jsonc new file mode 100644 index 0000000..e1a1d05 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/api/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "name": "api-worker", + "main": "index.ts", + "compatibility_date": "2026-06-01", + "routes": ["api.example.com/v1/*"], + "kv_namespaces": [{ "binding": "STORE", "id": "shared-store" }], + "d1_databases": [ + { + "binding": "DATABASE", + "database_name": "report-database", + "database_id": "fake-database-id", + }, + ], +} diff --git a/fixtures/create-test-harness-example/workers/mock-browser/index.ts b/fixtures/create-test-harness-example/workers/mock-browser/index.ts new file mode 100644 index 0000000..ac787aa --- /dev/null +++ b/fixtures/create-test-harness-example/workers/mock-browser/index.ts @@ -0,0 +1,19 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +let screenshot: Uint8Array | undefined; + +export default class MockBrowser extends WorkerEntrypoint { + setScreenshot(bytes: number[]) { + screenshot = Uint8Array.from(bytes); + } + + async quickAction(action: "screenshot", options: { html: string }) { + if (screenshot === undefined) { + throw new Error("Mock screenshot has not been configured."); + } + + return new Response(screenshot, { + headers: { "content-type": "image/png" }, + }); + } +} diff --git a/fixtures/create-test-harness-example/workers/mock-browser/wrangler.jsonc b/fixtures/create-test-harness-example/workers/mock-browser/wrangler.jsonc new file mode 100644 index 0000000..9aea212 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/mock-browser/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "mock-browser", + "main": "index.ts", + "compatibility_date": "2026-06-01", +} diff --git a/fixtures/create-test-harness-example/workers/web/index.ts b/fixtures/create-test-harness-example/workers/web/index.ts new file mode 100644 index 0000000..811ab52 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/web/index.ts @@ -0,0 +1,42 @@ +/** + * Web Worker: renders user profiles and reports by calling the API Worker over a service binding. + */ +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/") { + return new Response("Hello World"); + } + + const userPathPrefix = "/users/"; + if (url.pathname.startsWith(userPathPrefix)) { + const userId = url.pathname.slice(userPathPrefix.length); + const { name } = await env.API.getUser(userId); + return new Response(`Profile: ${name}`); + } + + const reportsPathPrefix = "/reports/"; + if (url.pathname.startsWith(reportsPathPrefix)) { + const date = url.pathname.slice(reportsPathPrefix.length).slice(0, 10); // YYYY-MM-DD + const report = await env.API.getDailyReport(date); + + if (report === null) { + return new Response("No report", { status: 404 }); + } + + if (url.pathname.endsWith(".png")) { + return env.BROWSER.quickAction("screenshot", { + html: `<h1>Daily report (${date}): active users ${report.join(", ")}</h1>`, + viewport: { width: 600, height: 200 }, + }); + } + + return new Response( + `Daily report (${date}): active users ${report.join(", ")}` + ); + } + + return new Response("Not Found", { status: 404 }); + }, +} satisfies ExportedHandler<WebEnv>; diff --git a/fixtures/create-test-harness-example/workers/web/worker-configuration.d.ts b/fixtures/create-test-harness-example/workers/web/worker-configuration.d.ts new file mode 100644 index 0000000..c08ea1d --- /dev/null +++ b/fixtures/create-test-harness-example/workers/web/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=./workers/web/wrangler.jsonc --config=./workers/api/wrangler.jsonc --include-runtime=false --env-interface=WebEnv ./workers/web/worker-configuration.d.ts` (hash: ba220acc5e7d9da5ae68234db2179e34) +interface __BaseEnv_WebEnv { + BROWSER: BrowserRun; + API: Service<typeof import("../api/index").default>; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_WebEnv {} +} +interface WebEnv extends __BaseEnv_WebEnv {} diff --git a/fixtures/create-test-harness-example/workers/web/wrangler.jsonc b/fixtures/create-test-harness-example/workers/web/wrangler.jsonc new file mode 100644 index 0000000..21777c0 --- /dev/null +++ b/fixtures/create-test-harness-example/workers/web/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "web-worker", + "main": "index.ts", + "compatibility_date": "2026-06-01", + "routes": ["example.com/*"], + "browser": { "binding": "BROWSER" }, + "services": [{ "binding": "API", "service": "api-worker" }], +} diff --git a/fixtures/d1-read-replication-app/.gitignore b/fixtures/d1-read-replication-app/.gitignore new file mode 100644 index 0000000..37cbd63 --- /dev/null +++ b/fixtures/d1-read-replication-app/.gitignore @@ -0,0 +1,2 @@ +dist +.wrangler diff --git a/fixtures/d1-read-replication-app/README.md b/fixtures/d1-read-replication-app/README.md new file mode 100644 index 0000000..a3a1fc3 --- /dev/null +++ b/fixtures/d1-read-replication-app/README.md @@ -0,0 +1,3 @@ +# Notes about testing with this fixture + +- This includes tests related to the D1 read replication feature. diff --git a/fixtures/d1-read-replication-app/package.json b/fixtures/d1-read-replication-app/package.json new file mode 100644 index 0000000..7858ea6 --- /dev/null +++ b/fixtures/d1-read-replication-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/d1-read-replication", + "private": true, + "scripts": { + "check:type": "tsc", + "start": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/d1-read-replication-app/src/index.ts b/fixtures/d1-read-replication-app/src/index.ts new file mode 100644 index 0000000..3dc1bce --- /dev/null +++ b/fixtures/d1-read-replication-app/src/index.ts @@ -0,0 +1,41 @@ +// These will become default after read replication is open. +type D1DatabaseWithSessionsAPI = D1Database & { + // constraintOrBookmark: "first-primary" | "first-unconstrained" | string + withSession(constraintOrBookmark: string): D1DatabaseSession; +}; + +type D1DatabaseSession = Pick<D1Database, "prepare" | "batch"> & { + getBookmark(): string; +}; + +export interface Env { + DB01: D1DatabaseWithSessionsAPI; +} + +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + + let bookmark = "first-primary"; + let q = "select 1;"; + + if (url.pathname === "/sql") { + bookmark = url.searchParams.get("bookmark"); + q = url.searchParams.get("q"); + } + + const session = env.DB01.withSession(bookmark); + // Dummy select to get the bookmark before the main query. + await session.prepare("select 1").all(); + const bookmarkBefore = session.getBookmark(); + // Now do the main query requested. + const result = await session.prepare(q).all(); + const bookmarkAfter = session.getBookmark(); + + return Response.json({ + bookmarkBefore, + bookmarkAfter, + result, + }); + }, +}; diff --git a/fixtures/d1-read-replication-app/tests/index.test.ts b/fixtures/d1-read-replication-app/tests/index.test.ts new file mode 100644 index 0000000..5c40400 --- /dev/null +++ b/fixtures/d1-read-replication-app/tests/index.test.ts @@ -0,0 +1,82 @@ +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("d1-sessions-api - getBookmark", () => { + describe("with wrangler dev", () => { + let ip: string, port: number, stop: (() => Promise<unknown>) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should respond with bookmarks before and after a session query", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}`); + let parsed = await response.json(); + expect(response.status).toBe(200); + expect(parsed).toMatchObject({ + bookmarkBefore: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/), + bookmarkAfter: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/), + }); + }); + + it("should progress the bookmark after a write", async ({ expect }) => { + let response = await fetch( + `http://${ip}:${port}?q=${encodeURIComponent("create table if not exists users1(id text);")}` + ); + let parsed = (await response.json()) as { + bookmarkAfter: string; + bookmarkBefore: string; + }; + expect(response.status).toBe(200); + expect(parsed).toMatchObject({ + bookmarkBefore: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/), + bookmarkAfter: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/), + }); + expect( + parsed.bookmarkAfter > parsed.bookmarkBefore, + `before[${parsed.bookmarkBefore}] !== after[${parsed.bookmarkAfter}]` + ).toEqual(true); + }); + + it("should maintain the latest bookmark after many queries", async ({ + expect, + }) => { + let responses = []; + + for (let i = 0; i < 10; i++) { + const resp = await fetch( + `http://${ip}:${port}?q=${encodeURIComponent(`create table if not exists users${i}(id text);`)}` + ); + let parsed = (await resp.json()) as { + bookmarkAfter: string; + bookmarkBefore: string; + }; + expect(resp.status).toBe(200); + responses.push(parsed); + + expect( + parsed.bookmarkAfter > parsed.bookmarkBefore, + `before[${parsed.bookmarkBefore}] !== after[${parsed.bookmarkAfter}]` + ).toEqual(true); + } + + const lastBookmark = responses.at(-1)?.bookmarkAfter; + responses.slice(0, -1).forEach((parsed) => { + expect( + parsed.bookmarkAfter < lastBookmark!, + `previous after[${parsed.bookmarkAfter}] !< lastBookmark[${lastBookmark}]` + ).toEqual(true); + }); + }); + }); +}); diff --git a/fixtures/d1-read-replication-app/tests/tsconfig.json b/fixtures/d1-read-replication-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/d1-read-replication-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/d1-read-replication-app/tsconfig.json b/fixtures/d1-read-replication-app/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/d1-read-replication-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/d1-read-replication-app/vitest.config.mts b/fixtures/d1-read-replication-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/d1-read-replication-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/d1-read-replication-app/wrangler.jsonc b/fixtures/d1-read-replication-app/wrangler.jsonc new file mode 100644 index 0000000..40e129b --- /dev/null +++ b/fixtures/d1-read-replication-app/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "name": "d1-read-replication-app", + "main": "src/index.ts", + "compatibility_date": "2025-03-11", + "compatibility_flags": [ + "nodejs_compat", + "experimental", + "enable_d1_with_sessions_api", + ], + "d1_databases": [ + { + "binding": "DB01", // i.e. available in your Worker on env.DB01 + "database_name": "UPDATE_THIS_FOR_REMOTE_USE", + "preview_database_id": "UPDATE_THIS_FOR_REMOTE_USE", + "database_id": "UPDATE_THIS_FOR_REMOTE_USE", + }, + ], +} diff --git a/fixtures/d1-worker-app/.gitignore b/fixtures/d1-worker-app/.gitignore new file mode 100644 index 0000000..37cbd63 --- /dev/null +++ b/fixtures/d1-worker-app/.gitignore @@ -0,0 +1,2 @@ +dist +.wrangler diff --git a/fixtures/d1-worker-app/README.md b/fixtures/d1-worker-app/README.md new file mode 100644 index 0000000..692820c --- /dev/null +++ b/fixtures/d1-worker-app/README.md @@ -0,0 +1,3 @@ +# Notes about testing with this fixture + +- For some reason, miniflare requires exactly node version 16 to be able to run `wrangler dev --local` diff --git a/fixtures/d1-worker-app/package.json b/fixtures/d1-worker-app/package.json new file mode 100644 index 0000000..d1f58da --- /dev/null +++ b/fixtures/d1-worker-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fixture/d1-basic", + "private": true, + "scripts": { + "check:type": "tsc", + "db:query": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --command='SELECT * FROM Customers'", + "db:query-json": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --command='SELECT * FROM Customers' --json", + "db:reset": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --file=./schema.sql", + "start": "wrangler dev --local", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/d1-worker-app/schema.sql b/fixtures/d1-worker-app/schema.sql new file mode 100644 index 0000000..234f5f3 --- /dev/null +++ b/fixtures/d1-worker-app/schema.sql @@ -0,0 +1,11 @@ +/**** Test comments ****/ +DROP TABLE /**** comment ****/ IF EXISTS Customers; +CREATE TABLE Customers (CustomerID INT,/**** + comment + ****/ + CompanyName TEXT, + ContactName TEXT, + PRIMARY KEY (`CustomerID`) +); +INSERT INTO Customers (CustomerID, CompanyName, ContactName) VALUES (1, 'Alfreds Futterkiste', 'Maria Anders'), (4, 'Around the Horn', 'Thomas Hardy'), (11, 'Bs Beverages', 'Victoria Ashworth'), (13, 'Bs Beverages', 'Random Name'); -- comment +-- Hi \ No newline at end of file diff --git a/fixtures/d1-worker-app/src/index.ts b/fixtures/d1-worker-app/src/index.ts new file mode 100644 index 0000000..cfe26d6 --- /dev/null +++ b/fixtures/d1-worker-app/src/index.ts @@ -0,0 +1,12 @@ +export default { + async fetch(request, env) { + return Response.json(env); + }, +}; +export class DurableObjectExample { + constructor(state, env) {} + + async fetch(request) { + return new Response("Hello World"); + } +} diff --git a/fixtures/d1-worker-app/tests/index.test.ts b/fixtures/d1-worker-app/tests/index.test.ts new file mode 100644 index 0000000..a296af6 --- /dev/null +++ b/fixtures/d1-worker-app/tests/index.test.ts @@ -0,0 +1,15 @@ +import { execSync } from "node:child_process"; +import { resolve } from "node:path"; +import { describe, it } from "vitest"; + +describe("d1", () => { + describe("d1 execute --local", () => { + it("should execute SQL file against the local db", ({ expect }) => { + const stdout = execSync("pnpm run db:reset", { + cwd: resolve(__dirname, ".."), + }).toString(); + + expect(stdout).toContain("🚣 3 commands executed successfully."); + }); + }); +}); diff --git a/fixtures/d1-worker-app/tests/tsconfig.json b/fixtures/d1-worker-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/d1-worker-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/d1-worker-app/tsconfig.json b/fixtures/d1-worker-app/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/d1-worker-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/d1-worker-app/vitest.config.mts b/fixtures/d1-worker-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/d1-worker-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/d1-worker-app/wrangler.jsonc b/fixtures/d1-worker-app/wrangler.jsonc new file mode 100644 index 0000000..4445a49 --- /dev/null +++ b/fixtures/d1-worker-app/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "name": "worker-app", + "main": "src/index.js", + "compatibility_date": "2022-03-31", + "d1_databases": [ + { + "binding": "DB", // i.e. available in your Worker on env.DB + "database_name": "UPDATE_THIS_FOR_REMOTE_USE", + "preview_database_id": "UPDATE_THIS_FOR_REMOTE_USE", + "database_id": "UPDATE_THIS_FOR_REMOTE_USE", + }, + ], + "migrations": [ + { + "tag": "v1", // Should be unique for each entry + "new_classes": ["DurableObjectExample"], // Array of new classes + }, + ], + "durable_objects": { + "bindings": [ + { + // Binding to our DurableObjectExample class + "name": "EXAMPLE_CLASS", + "class_name": "DurableObjectExample", + }, + ], + }, +} diff --git a/fixtures/dev-registry/assets/example.txt b/fixtures/dev-registry/assets/example.txt new file mode 100644 index 0000000..04a5e8c --- /dev/null +++ b/fixtures/dev-registry/assets/example.txt @@ -0,0 +1 @@ +This is an example asset file \ No newline at end of file diff --git a/fixtures/dev-registry/package.json b/fixtures/dev-registry/package.json new file mode 100644 index 0000000..c3a3b5c --- /dev/null +++ b/fixtures/dev-registry/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/dev-registry", + "private": true, + "type": "module", + "scripts": { + "check:types": "tsc --build", + "test": "vitest", + "test:ci": "vitest run", + "vite": "vite dev", + "wrangler": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "vite": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts new file mode 100644 index 0000000..249f58a --- /dev/null +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -0,0 +1,1381 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolve } from "node:path"; +/* eslint-disable workers-sdk/no-vitest-import-expect -- uses expect in module-scope helper functions */ +import { + describe, + expect, + onTestFailed, + onTestFinished, + test, + vi, +} from "vitest"; +/* eslint-enable workers-sdk/no-vitest-import-expect */ +import { + runLongLived, + waitForReady, +} from "../../../packages/vite-plugin-cloudflare/e2e/helpers"; +import { runWranglerDev as baseRunWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +const waitForTimeout = 20_000; +const cwd = resolve(__dirname, ".."); +const tmpPathBase = path.join(os.tmpdir(), "wrangler-tests"); +const it = test.extend<{ + devRegistryPath: string; +}>({ + // Fixture for creating a temporary directory + async devRegistryPath({}, use) { + const tmpPath = await fs.realpath(await fs.mkdtemp(tmpPathBase)); + await use(tmpPath); + await fs.rm(tmpPath, { recursive: true, maxRetries: 10 }); + }, +}); + +async function runViteDev( + config: string, + devRegistryPath?: string +): Promise<string> { + const proc = await runLongLived("pnpm", `vite --config ${config}`, cwd, { + MINIFLARE_REGISTRY_PATH: devRegistryPath, + }); + const url = await waitForReady(proc); + + onTestFailed(() => { + console.log(`::group::Vite dev session (${config})`); + console.log(proc.stdout); + console.log(proc.stderr); + console.log("::endgroup::"); + }); + + // Wait for the dev session to be ready + await vi.waitFor(async () => { + const resposne = await fetch(url, { method: "HEAD" }); + expect(resposne.status).toBe(200); + }, waitForTimeout); + + return url; +} + +async function runWranglerDev( + config: string | string[], + devRegistryPath?: string +): Promise<string> { + const session = await baseRunWranglerDev( + cwd, + ["--port=0", "--inspector-port=0"].concat( + Array.isArray(config) + ? config.map((configPath) => `--config=${configPath}`) + : [`--config=${config}`] + ), + { WRANGLER_REGISTRY_PATH: devRegistryPath } + ); + + onTestFailed(() => { + console.log(`::group::Wrangler dev session (${config})`); + console.log(session.getOutput()); + console.log("::endgroup::"); + }); + onTestFinished(() => session.stop()); + + const url = `http://${session.ip}:${session.port}`; + + // Wait for the dev session to be ready + await vi.waitFor(async () => { + const resposne = await fetch(url); + expect(resposne.status).not.toBeGreaterThan(500); + }, waitForTimeout); + + return url; +} + +async function setupPlatformProxy(config: string, devRegistryPath?: string) { + vi.stubEnv("WRANGLER_REGISTRY_PATH", devRegistryPath); + + onTestFinished(() => { + vi.unstubAllEnvs(); + }); + + const wrangler = await import("wrangler"); + const proxy = await wrangler.getPlatformProxy<Record<string, any>>({ + configPath: config, + }); + + onTestFinished(() => proxy.dispose()); + + return proxy; +} + +describe("Dev Registry: wrangler dev <-> wrangler dev", () => { + it("supports service worker fetch over service binding", async ({ + devRegistryPath, + }) => { + const exportedHandler = await runWranglerDev( + "wrangler.exported-handler.jsonc", + devRegistryPath + ); + + // Test fallback service before service-worker is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "service-worker", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect({ + status: response.status, + body: await response.text(), + }).toEqual({ + status: 503, + body: `Worker "service-worker" not found. Make sure it is running locally.`, + }); + }, waitForTimeout); + + await runWranglerDev("wrangler.service-worker.jsonc", devRegistryPath); + + // Test exported-handler -> service worker + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "service-worker", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(await response.text()).toBe("Hello from service worker!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports exported handler fetch over service binding", async ({ + devRegistryPath, + }) => { + const singleWorkerWithAssets = await runWranglerDev( + "wrangler.worker-entrypoint-with-assets.jsonc", + devRegistryPath + ); + + // Test fallback before exported-handler is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch(`${singleWorkerWithAssets}?${searchParams}`); + + expect({ + status: response.status, + body: await response.text(), + }).toEqual({ + status: 503, + body: `Worker "exported-handler" not found. Make sure it is running locally.`, + }); + }, waitForTimeout); + + const multiWorkers = await runWranglerDev( + ["wrangler.exported-handler.jsonc", "wrangler.worker-entrypoint.jsonc"], + devRegistryPath + ); + + // Test multi workers -> single worker + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${multiWorkers}?${searchParams}`); + + expect(await response.text()).toBe("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + + // Test fetching asset from "worker-entrypoint-with-assets" over service binding + // Exported handler has no assets, so it will hit the user worker and + // forward the request to "worker-entrypoint-with-assets" with the asset path + const assetResponse = await fetch( + `${multiWorkers}/example.txt?${searchParams}` + ); + expect(await assetResponse.text()).toBe("This is an example asset file"); + }, waitForTimeout); + + // Test single worker -> multi workers + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch(`${singleWorkerWithAssets}?${searchParams}`); + + expect(await response.text()).toEqual("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test single worker -> named entrypoint + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${singleWorkerWithAssets}?${searchParams}`); + + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test multi workers -> named entrypoint with assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${multiWorkers}?${searchParams}`); + + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports RPC over service binding", async ({ devRegistryPath }) => { + const multiWorkers = await runWranglerDev( + [ + "wrangler.worker-entrypoint.jsonc", + "wrangler.internal-durable-object.jsonc", + ], + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${multiWorkers}?${searchParams}`); + + expect(response.status).toBe(500); + expect(await response.text()).toEqual( + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + const singleWorkerWithAssets = await runWranglerDev( + "wrangler.worker-entrypoint-with-assets.jsonc", + devRegistryPath + ); + + // Test RPC to default entrypoint + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${singleWorkerWithAssets}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); + + // Test RPC to default entrypoint with static assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${multiWorkers}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); + + // Test RPC to named entrypoint + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${singleWorkerWithAssets}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); + + // Test RPC to named entrypoint with static assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${multiWorkers}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); + }); + + it("supports fetch over durable object binding", async ({ + devRegistryPath, + }) => { + const externalDurableObject = await runWranglerDev( + "wrangler.external-durable-object.jsonc", + devRegistryPath + ); + + // Test fallback before internal durable object is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "fetch", + }); + const response = await fetch(`${externalDurableObject}?${searchParams}`); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "internal-durable-object" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runWranglerDev( + [ + "wrangler.internal-durable-object.jsonc", + "wrangler.exported-handler.jsonc", + ], + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "fetch", + }); + const response = await fetch(`${externalDurableObject}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Hello from Durable Object!"); + }, waitForTimeout); + }); + + it("supports RPC over durable object binding", async ({ + devRegistryPath, + }) => { + const externalDurableObject = await runWranglerDev( + [ + "wrangler.external-durable-object.jsonc", + "wrangler.exported-handler.jsonc", + ], + devRegistryPath + ); + + // Test RPC fallback before internal durable object is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "rpc", + }); + const response = await fetch(`${externalDurableObject}?${searchParams}`); + + expect(response.status).toBe(500); + expect(await response.text()).toContain( + `Worker "internal-durable-object" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runWranglerDev( + "wrangler.internal-durable-object.jsonc", + devRegistryPath + ); + + // Test RPC after internal durable object is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "rpc", + }); + const response = await fetch(`${externalDurableObject}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); + }); + + it("supports tail handler", async ({ devRegistryPath }) => { + const exportedHandlerWithAssets = await runWranglerDev( + "wrangler.exported-handler-with-assets.jsonc", + devRegistryPath + ); + const workerEntrypoint = await runWranglerDev( + [ + "wrangler.worker-entrypoint.jsonc", + "wrangler.internal-durable-object.jsonc", + ], + devRegistryPath + ); + + const searchParams = new URLSearchParams({ + "test-method": "tail", + }); + + await vi.waitFor(async () => { + // Trigger tail handler of worker-entrypoint via exported handler + await fetch(`${exportedHandlerWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello world", "this is the 2nd log"]), + }); + await fetch(`${exportedHandlerWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["some other log"]), + }); + + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + + expect(await response.json()).toEqual({ + worker: "Worker Entrypoint", + tailEvents: expect.arrayContaining([ + [["[exported-handler]"], ["hello world", "this is the 2nd log"]], + [["[exported-handler]"], ["some other log"]], + ]), + }); + }, waitForTimeout); + + await vi.waitFor(async () => { + // Trigger tail handler of exported-handler via worker-entrypoint + await fetch(`${workerEntrypoint}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello from test"]), + }); + await fetch(`${workerEntrypoint}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["yet another log", "and another one"]), + }); + const response = await fetch( + `${exportedHandlerWithAssets}?${searchParams}` + ); + + expect(await response.json()).toEqual({ + worker: "exported-handler", + tailEvents: expect.arrayContaining([ + [ + ["[worker-entrypoint]", "[Worker Entrypoint]"], + ["[worker-entrypoint]", "hello from test"], + ], + [ + ["[worker-entrypoint]", "[Worker Entrypoint]"], + ["[worker-entrypoint]", "yet another log", "and another one"], + ], + ]), + }); + }, waitForTimeout); + }); + + it("supports queues across dev sessions", async ({ devRegistryPath }) => { + const exportedHandler = await runWranglerDev( + "wrangler.exported-handler.jsonc", + devRegistryPath + ); + const sendParams = new URLSearchParams({ + "test-method": "queue-send", + }); + const receivedParams = new URLSearchParams({ + "test-method": "queue-received", + }); + + // Sending with no consumer session running mirrors the local no-consumer + // behaviour: the message is dropped, but `send()` still succeeds + await vi.waitFor(async () => { + const response = await fetch(`${exportedHandler}?${sendParams}`, { + method: "POST", + body: "dropped message", + }); + + expect(await response.text()).toBe("Queued"); + expect(response.status).toBe(200); + }, waitForTimeout); + + const workerEntrypoint = await runWranglerDev( + "wrangler.worker-entrypoint.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const sendResponse = await fetch(`${exportedHandler}?${sendParams}`, { + method: "POST", + body: "hello from producer", + }); + expect(sendResponse.status).toBe(200); + + const receivedResponse = await fetch( + `${workerEntrypoint}?${receivedParams}` + ); + const received = await receivedResponse.json(); + expect(received).toContain("hello from producer"); + // Messages sent before the consumer session started are dropped, not + // buffered + expect(received).not.toContain("dropped message"); + }, waitForTimeout); + }); +}); + +describe("Dev Registry: vite dev <-> vite dev", () => { + it("supports exported handler fetch over service binding", async ({ + devRegistryPath, + }) => { + const workerEntrypointWithAssets = await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath + ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + + // Test fallback before exported-handler is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "exported-handler" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + + // Test exported-handler -> worker-entrypoint-with-assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(await response.text()).toBe("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + + // Test fetching asset from "worker-entrypoint-with-assets" over service binding + // Exported handler has no assets, so it will hit the user worker and + // forward the request to "worker-entrypoint-with-assets" with the asset path + const assetResponse = await fetch( + `${exportedHandler}/example.txt?${searchParams}` + ); + expect(await assetResponse.text()).toBe("This is an example asset file"); + }, waitForTimeout); + + // Test worker-entrypoint-with-assets -> exported-handler + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); + + expect(await response.text()).toEqual("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test exported-handler -> named-entrypoint + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test exported-handler -> named-entrypoint-with-assets + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(await response.text()).toEqual("Hello from Named Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports RPC over service binding", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + + // Test fallback before worker-entrypoint-with-assets is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(500); + expect(await response.text()).toEqual( + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath + ); + + // Test exported-handler -> worker-entrypoint RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); + + // Test exported-handler -> worker-entrypoint-with-assets RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong"); + }, waitForTimeout); + + // Test exported-handler -> named-entrypoint RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); + + // Test exported-handler -> named-entrypoint-with-assets RPC + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Pong from Named Entrypoint"); + }, waitForTimeout); + }); + + it("supports WebSocket upgrade over service binding", async ({ + devRegistryPath, + }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + + // Test exported-handler -> worker-entrypoint WebSocket proxy + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "websocket-proxy", + }); + const wsUrl = `${exportedHandler.replace("http", "ws")}?${searchParams}`; + const ws = new WebSocket(wsUrl); + + const message = await new Promise<string>((resolve, reject) => { + ws.addEventListener("open", () => ws.send("hello")); + ws.addEventListener("message", (event) => { + resolve(String(event.data)); + ws.close(); + }); + ws.addEventListener("error", () => + reject(new Error("WebSocket connection failed")) + ); + }); + + expect(message).toBe("echo:hello"); + }, waitForTimeout); + }); + + it("supports tail handler", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + const workerEntrypointWithAssets = await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath + ); + + const searchParams = new URLSearchParams({ + "test-method": "tail", + }); + + await vi.waitFor(async () => { + // Trigger tail handler of worker-entrypoint via exported-handler + await fetch(`${exportedHandler}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello world", "this is the 2nd log"]), + }); + await fetch(`${exportedHandler}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["some other log"]), + }); + + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); + + expect(await response.json()).toEqual({ + worker: "Worker Entrypoint", + tailEvents: expect.arrayContaining([ + [["[exported-handler]"], ["hello world", "this is the 2nd log"]], + [["[exported-handler]"], ["some other log"]], + ]), + }); + }, waitForTimeout); + + await vi.waitFor(async () => { + // Trigger tail handler of exported-handler via worker-entrypoint + await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello from test"]), + }); + await fetch(`${workerEntrypointWithAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["yet another log", "and another one"]), + }); + + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(await response.json()).toEqual({ + worker: "exported-handler", + tailEvents: expect.arrayContaining([ + [["[Worker Entrypoint]"], ["hello from test"]], + [["[Worker Entrypoint]"], ["yet another log", "and another one"]], + ]), + }); + }, waitForTimeout); + }); + + it("supports queues across dev sessions", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + const workerEntrypoint = await runViteDev( + "vite.worker-entrypoint.config.ts", + devRegistryPath + ); + + await vi.waitFor(async () => { + const sendParams = new URLSearchParams({ + "test-method": "queue-send", + }); + const sendResponse = await fetch(`${exportedHandler}?${sendParams}`, { + method: "POST", + body: "hello from vite producer", + }); + expect(await sendResponse.text()).toBe("Queued"); + expect(sendResponse.status).toBe(200); + + const receivedParams = new URLSearchParams({ + "test-method": "queue-received", + }); + const receivedResponse = await fetch( + `${workerEntrypoint}?${receivedParams}` + ); + expect(await receivedResponse.json()).toContain( + "hello from vite producer" + ); + }, waitForTimeout); + }); +}); + +describe("Dev Registry: vite dev <-> wrangler dev", () => { + it("uses the same dev registry path by default", async () => { + const workerEntrypoint = await runViteDev( + "vite.worker-entrypoint.config.ts" + ); + const exportedHandler = await runWranglerDev( + "wrangler.exported-handler.jsonc" + ); + + // Test wrangler dev -> vite dev yet + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + expect(await response.text()).toBe("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test vite dev -> wrangler dev + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + expect(await response.text()).toBe("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports exported handler fetch over service binding", async ({ + devRegistryPath, + }) => { + const workerEntrypoint = await runViteDev( + "vite.worker-entrypoint.config.ts", + devRegistryPath + ); + + // Test fallback service before exported-handler is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + + expect(await response.text()).toEqual( + `Worker "exported-handler" not found. Make sure it is running locally.` + ); + expect(response.status).toBe(503); + }, waitForTimeout); + + const exportedHandler = await runWranglerDev( + "wrangler.exported-handler.jsonc", + devRegistryPath + ); + + // Test wrangler dev -> vite dev yet + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + expect(await response.text()).toBe("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + // Test vite dev -> wrangler dev + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "exported-handler", + "test-method": "fetch", + }); + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + expect(await response.text()).toBe("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports service worker fetch over service binding", async ({ + devRegistryPath, + }) => { + const viteDevURL = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + + // Test fallback before exported-handler is started + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "service-worker", + "test-method": "fetch", + }); + const response = await fetch(`${viteDevURL}?${searchParams}`); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "service-worker" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runWranglerDev( + ["wrangler.service-worker.jsonc", "wrangler.worker-entrypoint.jsonc"], + devRegistryPath + ); + + // Test vite dev -> wrangler dev + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "service-worker", + "test-method": "fetch", + }); + const response = await fetch(`${viteDevURL}?${searchParams}`); + expect(await response.text()).toEqual("Hello from service worker!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports RPC over service binding", async ({ devRegistryPath }) => { + const workerEntrypoint = await runViteDev( + "vite.worker-entrypoint.config.ts", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + expect(response.status).toBe(500); + expect(await response.text()).toEqual( + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + const workerEntrypointWithAssets = await runWranglerDev( + "wrangler.worker-entrypoint-with-assets.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint", + "test-method": "rpc", + }); + const response = await fetch( + `${workerEntrypointWithAssets}?${searchParams}` + ); + expect(await response.text()).toEqual("Pong"); + expect(response.status).toBe(200); + }, waitForTimeout); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "worker-entrypoint-with-assets", + "test-method": "rpc", + }); + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + expect(await response.text()).toEqual("Pong"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports tail handler", async ({ devRegistryPath }) => { + const exportedHandlerWithStaticAssets = await runViteDev( + "vite.exported-handler-with-assets.config.ts", + devRegistryPath + ); + const workerEntrypoint = await runWranglerDev( + "wrangler.worker-entrypoint.jsonc", + devRegistryPath + ); + + const searchParams = new URLSearchParams({ + "test-method": "tail", + }); + + await vi.waitFor(async () => { + // Trigger tail handler of worker-entrypoint via exported-handler + await fetch(`${exportedHandlerWithStaticAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello world", "this is the 2nd log"]), + }); + await fetch(`${exportedHandlerWithStaticAssets}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["some other log"]), + }); + + const response = await fetch(`${workerEntrypoint}?${searchParams}`); + + expect(await response.json()).toEqual({ + worker: "Worker Entrypoint", + tailEvents: expect.arrayContaining([ + [["[exported-handler]"], ["hello world", "this is the 2nd log"]], + [["[exported-handler]"], ["some other log"]], + ]), + }); + }, waitForTimeout); + + await vi.waitFor(async () => { + // Trigger tail handler of exported-handler via worker-entrypoint + await fetch(`${workerEntrypoint}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["hello from test"]), + }); + await fetch(`${workerEntrypoint}?${searchParams}`, { + method: "POST", + body: JSON.stringify(["yet another log", "and another one"]), + }); + + const response = await fetch( + `${exportedHandlerWithStaticAssets}?${searchParams}` + ); + + expect(await response.json()).toEqual({ + worker: "exported-handler", + tailEvents: expect.arrayContaining([ + [["[Worker Entrypoint]"], ["hello from test"]], + [["[Worker Entrypoint]"], ["yet another log", "and another one"]], + ]), + }); + }, waitForTimeout); + }); + + it("supports queues across dev sessions", async ({ devRegistryPath }) => { + const exportedHandler = await runViteDev( + "vite.exported-handler.config.ts", + devRegistryPath + ); + const workerEntrypoint = await runWranglerDev( + "wrangler.worker-entrypoint.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const sendParams = new URLSearchParams({ + "test-method": "queue-send", + }); + const sendResponse = await fetch(`${exportedHandler}?${sendParams}`, { + method: "POST", + body: "hello from vite to wrangler", + }); + expect(await sendResponse.text()).toBe("Queued"); + expect(sendResponse.status).toBe(200); + + const receivedParams = new URLSearchParams({ + "test-method": "queue-received", + }); + const receivedResponse = await fetch( + `${workerEntrypoint}?${receivedParams}` + ); + expect(await receivedResponse.json()).toContain( + "hello from vite to wrangler" + ); + }, waitForTimeout); + }); +}); + +describe("Dev Registry: getPlatformProxy -> wrangler / vite dev", () => { + it("supports fetch over service binding", async ({ devRegistryPath }) => { + const { env } = await setupPlatformProxy( + "wrangler.worker-entrypoint.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const response = + await env.WORKER_ENTRYPOINT_WITH_ASSETS.fetch("http://localhost"); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await vi.waitFor(async () => { + const response = await env.EXPORTED_HANDLER.fetch("http://localhost"); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "exported-handler" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runViteDev( + "vite.worker-entrypoint-with-assets.config.ts", + devRegistryPath + ); + + await vi.waitFor(async () => { + const response = + await env.WORKER_ENTRYPOINT_WITH_ASSETS.fetch("http://localhost"); + + expect(await response.text()).toEqual("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + await vi.waitFor(async () => { + const response = await env.EXPORTED_HANDLER.fetch("http://localhost"); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "exported-handler" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runWranglerDev("wrangler.exported-handler.jsonc", devRegistryPath); + + await vi.waitFor(async () => { + const response = await env.EXPORTED_HANDLER.fetch("http://localhost"); + + expect(await response.text()).toEqual("Hello from exported handler!"); + expect(response.status).toBe(200); + }, waitForTimeout); + + await vi.waitFor(async () => { + const response = + await env.WORKER_ENTRYPOINT_WITH_ASSETS.fetch("http://localhost"); + + expect(await response.text()).toEqual("Hello from Worker Entrypoint!"); + expect(response.status).toBe(200); + }, waitForTimeout); + }); + + it("supports RPC over service binding", async ({ devRegistryPath }) => { + const { env } = await setupPlatformProxy( + "wrangler.exported-handler.jsonc", + devRegistryPath + ); + + expect(() => + env.WORKER_ENTRYPOINT.ping() + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Worker "worker-entrypoint" not found. Make sure it is running locally.]` + ); + + expect(() => + env.WORKER_ENTRYPOINT_WITH_ASSETS.ping() + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.]` + ); + + await runViteDev("vite.worker-entrypoint.config.ts", devRegistryPath); + + await vi.waitFor(async () => { + const result = await env.WORKER_ENTRYPOINT.ping(); + expect(result).toBe("Pong"); + }, waitForTimeout); + + await runWranglerDev( + [ + "wrangler.worker-entrypoint-with-assets.jsonc", + "wrangler.internal-durable-object.jsonc", + ], + devRegistryPath + ); + + await vi.waitFor(async () => { + const result = await env.WORKER_ENTRYPOINT_WITH_ASSETS.ping(); + + expect(result).toBe("Pong"); + }, waitForTimeout); + }); + + it("supports fetch over durable object binding", async ({ + devRegistryPath, + }) => { + const { env } = await setupPlatformProxy( + "wrangler.external-durable-object.jsonc", + devRegistryPath + ); + const id = env.DURABLE_OBJECT.newUniqueId(); + const stub = env.DURABLE_OBJECT.get(id); + + await vi.waitFor(async () => { + const response = await stub.fetch("http://localhost"); + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "internal-durable-object" not found. Make sure it is running locally.` + ); + }, waitForTimeout); + + await runWranglerDev( + "wrangler.internal-durable-object.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const response = await stub.fetch("http://localhost"); + + expect(response.status).toBe(200); + expect(await response.text()).toEqual("Hello from Durable Object!"); + }, waitForTimeout); + }); + + it("supports RPC over durable object binding", async ({ + devRegistryPath, + }) => { + const { env } = await setupPlatformProxy( + "wrangler.external-durable-object.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const id = env.DURABLE_OBJECT.newUniqueId(); + const stub = env.DURABLE_OBJECT.get(id); + + await expect(stub.ping()).rejects.toThrow(); + }, waitForTimeout); + + await runWranglerDev( + "wrangler.internal-durable-object.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const id = env.DURABLE_OBJECT.newUniqueId(); + const stub = env.DURABLE_OBJECT.get(id); + + const result = await stub.ping(); + expect(result).toEqual("Pong"); + }, waitForTimeout); + }); +}); + +describe("Dev Registry: error handling", () => { + it("returns an error when fetching a non-existent DO class on a running worker", async ({ + devRegistryPath, + }) => { + await runWranglerDev( + "wrangler.internal-durable-object.jsonc", + devRegistryPath + ); + const nonexistentDO = await runWranglerDev( + "wrangler.nonexistent-durable-object.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "fetch", + }); + const response = await fetch(`${nonexistentDO}?${searchParams}`); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain( + `Worker does not export a Durable Object class named "NonExistentObject"` + ); + }, waitForTimeout); + }); + + it("returns an error when calling RPC on a non-existent DO class on a running worker", async ({ + devRegistryPath, + }) => { + await runWranglerDev( + "wrangler.internal-durable-object.jsonc", + devRegistryPath + ); + const nonexistentDO = await runWranglerDev( + "wrangler.nonexistent-durable-object.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "durable-object", + "test-method": "rpc", + }); + const response = await fetch(`${nonexistentDO}?${searchParams}`); + + expect(response.status).toBe(500); + expect(await response.text()).toContain( + `Worker does not export a Durable Object class named "NonExistentObject"` + ); + }, waitForTimeout); + }); + + it("returns an error when fetching a non-existent entrypoint on a running worker", async ({ + devRegistryPath, + }) => { + await runWranglerDev("wrangler.worker-entrypoint.jsonc", devRegistryPath); + const nonexistentEntrypoint = await runWranglerDev( + "wrangler.nonexistent-entrypoint.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "fetch", + }); + const response = await fetch(`${nonexistentEntrypoint}?${searchParams}`); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain( + `Worker does not export an entrypoint named "NonExistentEntrypoint"` + ); + }, waitForTimeout); + }); + + it("returns an error when calling RPC on a non-existent entrypoint on a running worker", async ({ + devRegistryPath, + }) => { + await runWranglerDev("wrangler.worker-entrypoint.jsonc", devRegistryPath); + const nonexistentEntrypoint = await runWranglerDev( + "wrangler.nonexistent-entrypoint.jsonc", + devRegistryPath + ); + + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "named-entrypoint", + "test-method": "rpc", + }); + const response = await fetch(`${nonexistentEntrypoint}?${searchParams}`); + + expect(response.status).toBe(500); + expect(await response.text()).toContain( + `Worker does not export an entrypoint named "NonExistentEntrypoint"` + ); + }, waitForTimeout); + }); + + it("gracefully handles a stale registry entry from a prior wrangler version", async ({ + devRegistryPath, + }) => { + // Start a worker that has a service binding to "service-worker" + const exportedHandler = await runWranglerDev( + "wrangler.exported-handler.jsonc", + devRegistryPath + ); + + // Write an old-format registry entry (pre-native-registry) under the + // "service-worker" name. The old format used protocol/host/port/ + // entrypointAddresses instead of debugPortAddress/defaultEntrypointService. + await fs.mkdir(devRegistryPath, { recursive: true }); + await fs.writeFile( + path.join(devRegistryPath, "service-worker"), + JSON.stringify({ + protocol: "http", + host: "127.0.0.1", + port: 8787, + entrypointAddresses: { + default: { host: "127.0.0.1", port: 8787 }, + }, + durableObjects: [], + }) + ); + + // The old-format entry has no debugPortAddress, so the proxy should + // return a 503 with the incompatible version message rather than crashing. + // We wait specifically for the incompatible message (not the generic "not + // found" message) to ensure the file watcher has picked up the entry. + await vi.waitFor(async () => { + const searchParams = new URLSearchParams({ + "test-service": "service-worker", + "test-method": "fetch", + }); + const response = await fetch(`${exportedHandler}?${searchParams}`); + + expect(response.status).toBe(503); + expect(await response.text()).toEqual( + `Worker "service-worker" is not compatible with this version of the dev server. Please update all Worker instances to the same version.` + ); + }, waitForTimeout); + }); +}); diff --git a/fixtures/dev-registry/tests/tsconfig.json b/fixtures/dev-registry/tests/tsconfig.json new file mode 100644 index 0000000..8402dd8 --- /dev/null +++ b/fixtures/dev-registry/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/dev-registry/tsconfig.json b/fixtures/dev-registry/tsconfig.json new file mode 100644 index 0000000..b52af70 --- /dev/null +++ b/fixtures/dev-registry/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ] +} diff --git a/fixtures/dev-registry/tsconfig.node.json b/fixtures/dev-registry/tsconfig.node.json new file mode 100644 index 0000000..81e5be5 --- /dev/null +++ b/fixtures/dev-registry/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": ["@cloudflare/workers-tsconfig/base.json"], + "include": [ + "vite.exported-handler.config.ts", + "vite.exported-handler-with-assets.config.ts", + "vite.worker-entrypoint.config.ts", + "vite.worker-entrypoint-with-assets.config.ts", + "__tests__" + ] +} diff --git a/fixtures/dev-registry/tsconfig.worker.json b/fixtures/dev-registry/tsconfig.worker.json new file mode 100644 index 0000000..1b8a70d --- /dev/null +++ b/fixtures/dev-registry/tsconfig.worker.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental"] + }, + "include": ["workers"] +} diff --git a/fixtures/dev-registry/vite.exported-handler-with-assets.config.ts b/fixtures/dev-registry/vite.exported-handler-with-assets.config.ts new file mode 100644 index 0000000..664ca5e --- /dev/null +++ b/fixtures/dev-registry/vite.exported-handler-with-assets.config.ts @@ -0,0 +1,15 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + // Override the default public directory to use the assets directory + // so that other non assets vite projects won't share the same assets + publicDir: "./assets", + plugins: [ + cloudflare({ + configPath: "./wrangler.exported-handler-with-assets.jsonc", + inspectorPort: false, + persistState: false, + }), + ], +}); diff --git a/fixtures/dev-registry/vite.exported-handler.config.ts b/fixtures/dev-registry/vite.exported-handler.config.ts new file mode 100644 index 0000000..a06dbf3 --- /dev/null +++ b/fixtures/dev-registry/vite.exported-handler.config.ts @@ -0,0 +1,12 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "./wrangler.exported-handler.jsonc", + inspectorPort: false, + persistState: false, + }), + ], +}); diff --git a/fixtures/dev-registry/vite.worker-entrypoint-with-assets.config.ts b/fixtures/dev-registry/vite.worker-entrypoint-with-assets.config.ts new file mode 100644 index 0000000..ebea078 --- /dev/null +++ b/fixtures/dev-registry/vite.worker-entrypoint-with-assets.config.ts @@ -0,0 +1,20 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + // Override the default public directory to use the assets directory + // so that other vite projects won't share the same assets + publicDir: "./assets", + plugins: [ + cloudflare({ + configPath: "./wrangler.worker-entrypoint-with-assets.jsonc", + inspectorPort: false, + persistState: false, + auxiliaryWorkers: [ + { + configPath: "./wrangler.internal-durable-object.jsonc", + }, + ], + }), + ], +}); diff --git a/fixtures/dev-registry/vite.worker-entrypoint.config.ts b/fixtures/dev-registry/vite.worker-entrypoint.config.ts new file mode 100644 index 0000000..0e93190 --- /dev/null +++ b/fixtures/dev-registry/vite.worker-entrypoint.config.ts @@ -0,0 +1,17 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "./wrangler.worker-entrypoint.jsonc", + inspectorPort: false, + persistState: false, + auxiliaryWorkers: [ + { + configPath: "./wrangler.internal-durable-object.jsonc", + }, + ], + }), + ], +}); diff --git a/fixtures/dev-registry/vitest.config.mts b/fixtures/dev-registry/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/dev-registry/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/dev-registry/workers/durable-object.ts b/fixtures/dev-registry/workers/durable-object.ts new file mode 100644 index 0000000..94e9071 --- /dev/null +++ b/fixtures/dev-registry/workers/durable-object.ts @@ -0,0 +1,17 @@ +import { DurableObject } from "cloudflare:workers"; + +export class TestObject extends DurableObject { + ping() { + return "Pong"; + } + + fetch(request: Request) { + return new Response("Hello from Durable Object!"); + } +} + +export default { + fetch() { + return new Response(`I'm a teapot`, { status: 418 }); + }, +}; diff --git a/fixtures/dev-registry/workers/exported-handler.ts b/fixtures/dev-registry/workers/exported-handler.ts new file mode 100644 index 0000000..a9d2600 --- /dev/null +++ b/fixtures/dev-registry/workers/exported-handler.ts @@ -0,0 +1,126 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +let tailEvents = []; + +export class NamedEntrypoint extends WorkerEntrypoint { + ping() { + return "Pong from Named Entrypoint"; + } + + fetch() { + return new Response("Hello from Named Entrypoint!"); + } +} + +export default { + async fetch(request, env) { + try { + const url = new URL(request.url); + const testMethod = url.searchParams.get("test-method"); + const testService = url.searchParams.get("test-service"); + + // Remove the test-method search params to avoid recursion + url.searchParams.delete("test-method"); + url.searchParams.delete("test-service"); + + let service: Fetcher | undefined; + + switch (testService) { + case "service-worker": { + service = env.SERVICE_WORKER; + break; + } + case "worker-entrypoint": { + service = env.WORKER_ENTRYPOINT; + break; + } + case "worker-entrypoint-with-assets": { + service = env.WORKER_ENTRYPOINT_WITH_ASSETS; + break; + } + case "named-entrypoint": { + service = env.NAMED_ENTRYPOINT; + break; + } + case "named-entrypoint-with-assets": { + service = env.NAMED_ENTRYPOINT_WITH_ASSETS; + break; + } + } + + if (service && testMethod === "rpc") { + // @ts-expect-error + const result = await service.ping(); + return new Response(result); + } + + if (service && testMethod === "fetch") { + return await service.fetch(url); + } + + if (service && testMethod === "websocket-proxy") { + return await service.fetch(new Request(url, request)); + } + + // Handle WebSocket upgrades forwarded via service bindings + if (request.headers.get("Upgrade")) { + const { 0: client, 1: server } = new WebSocketPair(); + server.accept(); + server.addEventListener("message", (event) => { + server.send(`echo:${event.data}`); + }); + return new Response(null, { status: 101, webSocket: client }); + } + + if (testMethod === "queue-send") { + await env.QUEUE.send(await request.text()); + return new Response("Queued"); + } + + if (testMethod === "tail") { + if (request.method === "POST") { + const logs = await request.json(); + if (Array.isArray(logs)) { + console.log(`[exported-handler]`); + console.log(...logs); + } + return new Response("ok"); + } + + try { + return Response.json({ + worker: "exported-handler", + tailEvents, + }); + } finally { + // Clear the tail events after sending them + tailEvents = []; + } + } + + return new Response("Hello from exported handler!"); + } catch (e) { + return new Response(e.message, { status: 500 }); + } + }, + tail(events) { + const logs = []; + + for (const event of events) { + if (Array.isArray(event.logs) && event.logs.length > 0) { + logs.push(...event.logs.map((log) => log.message)); + } + } + + if (logs.length > 0) { + tailEvents.push(logs); + } + }, +} satisfies ExportedHandler<{ + SERVICE_WORKER: Fetcher; + WORKER_ENTRYPOINT: Fetcher; + WORKER_ENTRYPOINT_WITH_ASSETS: Fetcher; + NAMED_ENTRYPOINT: Fetcher; + NAMED_ENTRYPOINT_WITH_ASSETS: Fetcher; + QUEUE: Queue; +}>; diff --git a/fixtures/dev-registry/workers/service-worker.ts b/fixtures/dev-registry/workers/service-worker.ts new file mode 100644 index 0000000..3ed1cf4 --- /dev/null +++ b/fixtures/dev-registry/workers/service-worker.ts @@ -0,0 +1,3 @@ +addEventListener("fetch", (event) => { + event.respondWith(new Response("Hello from service worker!")); +}); diff --git a/fixtures/dev-registry/workers/worker-entrypoint.ts b/fixtures/dev-registry/workers/worker-entrypoint.ts new file mode 100644 index 0000000..60ad050 --- /dev/null +++ b/fixtures/dev-registry/workers/worker-entrypoint.ts @@ -0,0 +1,148 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +let tailEvents = []; +let queueMessages = []; + +export class NamedEntrypoint extends WorkerEntrypoint { + ping() { + return "Pong from Named Entrypoint"; + } + + fetch() { + return new Response("Hello from Named Entrypoint!"); + } +} + +export default class Worker extends WorkerEntrypoint<{ + SERVICE_WORKER: Fetcher; + EXPORTED_HANDLER: Fetcher; + WORKER_ENTRYPOINT: Fetcher; + WORKER_ENTRYPOINT_WITH_ASSETS: Fetcher; + NAMED_ENTRYPOINT: Fetcher; + NAMED_ENTRYPOINT_WITH_ASSETS: Fetcher; + DURABLE_OBJECT: DurableObjectNamespace; +}> { + ping() { + return "Pong"; + } + + async fetch(request) { + try { + const url = new URL(request.url); + const testMethod = url.searchParams.get("test-method"); + const testService = url.searchParams.get("test-service"); + + // Remove the test-method search params to avoid recursion + url.searchParams.delete("test-method"); + url.searchParams.delete("test-service"); + + let service: Fetcher | undefined; + + switch (testService) { + case "service-worker": { + service = this.env.SERVICE_WORKER; + break; + } + case "exported-handler": { + service = this.env.EXPORTED_HANDLER; + break; + } + case "worker-entrypoint": { + service = this.env.WORKER_ENTRYPOINT; + break; + } + case "worker-entrypoint-with-assets": { + service = this.env.WORKER_ENTRYPOINT_WITH_ASSETS; + break; + } + case "named-entrypoint": { + service = this.env.NAMED_ENTRYPOINT; + break; + } + case "named-entrypoint-with-assets": { + service = this.env.NAMED_ENTRYPOINT_WITH_ASSETS; + break; + } + case "durable-object": { + const id = this.env.DURABLE_OBJECT.newUniqueId(); + const stub = this.env.DURABLE_OBJECT.get(id); + service = stub; + break; + } + } + + if (service && testMethod === "rpc") { + // @ts-expect-error + const result = await service.ping(); + return new Response(result); + } + + if (service && testMethod === "fetch") { + return await service.fetch(url, request); + } + + if (service && testMethod === "websocket-proxy") { + return await service.fetch(new Request(url, request)); + } + + // Handle WebSocket upgrades forwarded via service bindings + if (request.headers.get("Upgrade")) { + const { 0: client, 1: server } = new WebSocketPair(); + server.accept(); + server.addEventListener("message", (event) => { + server.send(`echo:${event.data}`); + }); + return new Response(null, { status: 101, webSocket: client }); + } + + if (testMethod === "queue-received") { + return Response.json(queueMessages); + } + + if (testMethod === "tail") { + if (request.method === "POST") { + const logs = await request.json(); + if (Array.isArray(logs)) { + console.log(`[Worker Entrypoint]`); + console.log(...logs); + } + return new Response("ok"); + } + + try { + return Response.json({ + worker: "Worker Entrypoint", + tailEvents, + }); + } finally { + // Clear the tail events after sending them + tailEvents = []; + } + } + + return new Response("Hello from Worker Entrypoint!"); + } catch (e) { + return new Response(e.message, { status: 500 }); + } + } + + tail(events) { + const logs = []; + + for (const event of events) { + if (Array.isArray(event.logs) && event.logs.length > 0) { + logs.push(...event.logs.map((log) => log.message)); + } + } + + if (logs.length > 0) { + tailEvents.push(logs); + } + } + + queue(batch) { + for (const message of batch.messages) { + queueMessages.push(message.body); + } + } +} diff --git a/fixtures/dev-registry/wrangler.exported-handler-with-assets.jsonc b/fixtures/dev-registry/wrangler.exported-handler-with-assets.jsonc new file mode 100644 index 0000000..322ca02 --- /dev/null +++ b/fixtures/dev-registry/wrangler.exported-handler-with-assets.jsonc @@ -0,0 +1,42 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "exported-handler-with-assets", + "main": "./workers/exported-handler.ts", + "compatibility_date": "2025-05-01", + "assets": { + "directory": "./assets", + }, + "services": [ + { + "binding": "SERVICE_WORKER", + "service": "service-worker", + }, + { + "binding": "EXPORTED_HANDLER", + "service": "exported-worker", + }, + { + "binding": "WORKER_ENTRYPOINT", + "service": "worker-entrypoint", + }, + { + "binding": "WORKER_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + }, + { + "binding": "NAMED_ENTRYPOINT", + "service": "worker-entrypoint", + "entrypoint": "NamedEntrypoint", + }, + { + "binding": "NAMED_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + "entrypoint": "NamedEntrypoint", + }, + ], + "tail_consumers": [ + { + "service": "worker-entrypoint", + }, + ], +} diff --git a/fixtures/dev-registry/wrangler.exported-handler.jsonc b/fixtures/dev-registry/wrangler.exported-handler.jsonc new file mode 100644 index 0000000..f8af467 --- /dev/null +++ b/fixtures/dev-registry/wrangler.exported-handler.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "exported-handler", + "main": "./workers/exported-handler.ts", + "compatibility_date": "2025-05-01", + "services": [ + { + "binding": "SERVICE_WORKER", + "service": "service-worker", + }, + { + "binding": "WORKER_ENTRYPOINT", + "service": "worker-entrypoint", + }, + { + "binding": "WORKER_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + }, + { + "binding": "NAMED_ENTRYPOINT", + "service": "worker-entrypoint", + "entrypoint": "NamedEntrypoint", + }, + { + "binding": "NAMED_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + "entrypoint": "NamedEntrypoint", + }, + ], + "tail_consumers": [ + { + "service": "worker-entrypoint-with-assets", + }, + ], + "queues": { + "producers": [ + { + "binding": "QUEUE", + "queue": "test-queue", + }, + ], + }, +} diff --git a/fixtures/dev-registry/wrangler.external-durable-object.jsonc b/fixtures/dev-registry/wrangler.external-durable-object.jsonc new file mode 100644 index 0000000..c74e28c --- /dev/null +++ b/fixtures/dev-registry/wrangler.external-durable-object.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "external-durable-object", + "main": "./workers/worker-entrypoint.ts", + "compatibility_date": "2025-05-01", + "durable_objects": { + "bindings": [ + { + "name": "DURABLE_OBJECT", + "class_name": "TestObject", + "script_name": "internal-durable-object", + }, + ], + }, + "tail_consumers": [ + { + "service": "exported-handler", + }, + ], +} diff --git a/fixtures/dev-registry/wrangler.internal-durable-object.jsonc b/fixtures/dev-registry/wrangler.internal-durable-object.jsonc new file mode 100644 index 0000000..4bfc327 --- /dev/null +++ b/fixtures/dev-registry/wrangler.internal-durable-object.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "internal-durable-object", + "main": "./workers/durable-object.ts", + "compatibility_date": "2025-05-01", + "durable_objects": { + "bindings": [ + { + "name": "DURABLE_OBJECT", + "class_name": "TestObject", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TestObject"], + }, + ], +} diff --git a/fixtures/dev-registry/wrangler.nonexistent-durable-object.jsonc b/fixtures/dev-registry/wrangler.nonexistent-durable-object.jsonc new file mode 100644 index 0000000..1bb3125 --- /dev/null +++ b/fixtures/dev-registry/wrangler.nonexistent-durable-object.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nonexistent-durable-object", + "main": "./workers/worker-entrypoint.ts", + "compatibility_date": "2025-05-01", + "durable_objects": { + "bindings": [ + { + "name": "DURABLE_OBJECT", + "class_name": "NonExistentObject", + "script_name": "internal-durable-object", + }, + ], + }, +} diff --git a/fixtures/dev-registry/wrangler.nonexistent-entrypoint.jsonc b/fixtures/dev-registry/wrangler.nonexistent-entrypoint.jsonc new file mode 100644 index 0000000..d566321 --- /dev/null +++ b/fixtures/dev-registry/wrangler.nonexistent-entrypoint.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nonexistent-entrypoint", + "main": "./workers/worker-entrypoint.ts", + "compatibility_date": "2025-05-01", + "services": [ + { + "binding": "NAMED_ENTRYPOINT", + "service": "worker-entrypoint", + "entrypoint": "NonExistentEntrypoint", + }, + ], +} diff --git a/fixtures/dev-registry/wrangler.service-worker.jsonc b/fixtures/dev-registry/wrangler.service-worker.jsonc new file mode 100644 index 0000000..f20ea63 --- /dev/null +++ b/fixtures/dev-registry/wrangler.service-worker.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "service-worker", + "main": "./workers/service-worker.ts", + "compatibility_date": "2025-05-01", +} diff --git a/fixtures/dev-registry/wrangler.worker-entrypoint-with-assets.jsonc b/fixtures/dev-registry/wrangler.worker-entrypoint-with-assets.jsonc new file mode 100644 index 0000000..77b873a --- /dev/null +++ b/fixtures/dev-registry/wrangler.worker-entrypoint-with-assets.jsonc @@ -0,0 +1,37 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "worker-entrypoint-with-assets", + "main": "./workers/worker-entrypoint.ts", + "compatibility_date": "2025-05-01", + "assets": { + "directory": "./assets", + }, + "services": [ + { + "binding": "SERVICE_WORKER", + "service": "service-worker", + }, + { + "binding": "EXPORTED_HANDLER", + "service": "exported-handler", + }, + { + "binding": "EXPORTED_HANDLER_WITH_ASSETS", + "service": "exported-handler-with-assets", + }, + { + "binding": "WORKER_ENTRYPOINT", + "service": "worker-entrypoint", + }, + { + "binding": "NAMED_ENTRYPOINT", + "service": "worker-entrypoint", + "entrypoint": "NamedEntrypoint", + }, + ], + "tail_consumers": [ + { + "service": "exported-handler", + }, + ], +} diff --git a/fixtures/dev-registry/wrangler.worker-entrypoint.jsonc b/fixtures/dev-registry/wrangler.worker-entrypoint.jsonc new file mode 100644 index 0000000..61ddb2e --- /dev/null +++ b/fixtures/dev-registry/wrangler.worker-entrypoint.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "worker-entrypoint", + "main": "./workers/worker-entrypoint.ts", + "compatibility_date": "2025-05-01", + "services": [ + { + "binding": "SERVICE_WORKER", + "service": "service-worker", + }, + { + "binding": "EXPORTED_HANDLER", + "service": "exported-handler", + }, + { + "binding": "EXPORTED_HANDLER_WITH_ASSETS", + "service": "exported-handler", + }, + { + "binding": "WORKER_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + }, + { + "binding": "NAMED_ENTRYPOINT_WITH_ASSETS", + "service": "worker-entrypoint-with-assets", + "entrypoint": "NamedEntrypoint", + }, + ], + "tail_consumers": [ + { + "service": "exported-handler-with-assets", + }, + ], + "queues": { + "consumers": [ + { + "queue": "test-queue", + "max_batch_size": 1, + "max_batch_timeout": 0, + }, + ], + }, +} diff --git a/fixtures/durable-objects-app/package.json b/fixtures/durable-objects-app/package.json new file mode 100644 index 0000000..27f85bd --- /dev/null +++ b/fixtures/durable-objects-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/worker-durable-objects", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "dev": "wrangler deploy --dry-run", + "test": "vitest run", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/durable-objects-app/src/index.js b/fixtures/durable-objects-app/src/index.js new file mode 100644 index 0000000..80f20d7 --- /dev/null +++ b/fixtures/durable-objects-app/src/index.js @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response("foo"); + }, +}; diff --git a/fixtures/durable-objects-app/tests/index.test.ts b/fixtures/durable-objects-app/tests/index.test.ts new file mode 100644 index 0000000..860d8a0 --- /dev/null +++ b/fixtures/durable-objects-app/tests/index.test.ts @@ -0,0 +1,26 @@ +import { execSync } from "node:child_process"; +import { resolve } from "node:path"; +import { assert, describe, it } from "vitest"; + +describe("durable objects", () => { + it("should throw an error when the worker doesn't export a durable object but requires one", ({ + expect, + }) => { + let err: string = ""; + try { + execSync("pnpm run dev", { + cwd: resolve(__dirname, ".."), + }); + assert(false); // Should never reach this + } catch (e) { + err = (e as Error).message + .replaceAll("✘", "X") + .replace(/\\/g, "/") + .replace(/[^\S\n]+\n/g, "\n") + .trimEnd(); + } + expect(err).toContain( + "You should export these objects from your entrypoint, src/index.js." + ); + }); +}); diff --git a/fixtures/durable-objects-app/tests/tsconfig.json b/fixtures/durable-objects-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/durable-objects-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/durable-objects-app/tsconfig.json b/fixtures/durable-objects-app/tsconfig.json new file mode 100644 index 0000000..59e6da4 --- /dev/null +++ b/fixtures/durable-objects-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/durable-objects-app/vitest.config.mts b/fixtures/durable-objects-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/durable-objects-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/durable-objects-app/wrangler.jsonc b/fixtures/durable-objects-app/wrangler.jsonc new file mode 100644 index 0000000..2df796d --- /dev/null +++ b/fixtures/durable-objects-app/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + "name": "do-worker-app", + "compatibility_date": "2022-03-31", + "main": "src/index.js", + "durable_objects": { + "bindings": [ + { + "name": "MY_DO", + "class_name": "FooBar", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["FooBar"], + }, + ], +} diff --git a/fixtures/durable-objects-exports-app/package.json b/fixtures/durable-objects-exports-app/package.json new file mode 100644 index 0000000..9ab6072 --- /dev/null +++ b/fixtures/durable-objects-exports-app/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixture/durable-objects-exports-app", + "private": true, + "description": "Fixture demonstrating the declarative Durable Object `exports` config (with `state` for tombstones)", + "license": "ISC", + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/durable-objects-exports-app/src/index.ts b/fixtures/durable-objects-exports-app/src/index.ts new file mode 100644 index 0000000..d9cd17e --- /dev/null +++ b/fixtures/durable-objects-exports-app/src/index.ts @@ -0,0 +1,93 @@ +import { DurableObject } from "cloudflare:workers"; + +interface Env { + COUNTER_A: DurableObjectNamespace<CounterA>; + COUNTER_B: DurableObjectNamespace<CounterB>; +} + +class SqlCounter extends DurableObject<Env> { + private readonly ready: Promise<void>; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ready = this.init(); + } + + private async init() { + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL DEFAULT 0)" + ); + this.ctx.storage.sql.exec( + "INSERT OR IGNORE INTO counter (id, value) VALUES (1, 0)" + ); + } + + async value(): Promise<number> { + await this.ready; + const row = this.ctx.storage.sql + .exec<{ value: number }>("SELECT value FROM counter WHERE id = 1") + .one(); + return row.value; + } + + async increment(): Promise<number> { + await this.ready; + this.ctx.storage.sql.exec( + "UPDATE counter SET value = value + 1 WHERE id = 1" + ); + return this.value(); + } + + async sqliteOk(): Promise<number> { + const row = this.ctx.storage.sql + .exec<{ ok: number }>("SELECT 1 AS ok") + .one(); + return row.ok; + } +} + +export class CounterA extends SqlCounter {} +export class CounterB extends SqlCounter {} +// CounterC has no binding in `wrangler.jsonc`; it is reached via `ctx.exports`. +export class CounterC extends SqlCounter {} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise<Response> { + const url = new URL(request.url); + const [, scope, action] = url.pathname.split("/"); + + let namespace: DurableObjectNamespace<SqlCounter> | undefined; + if (scope === "a") { + namespace = env.COUNTER_A; + } else if (scope === "b") { + namespace = env.COUNTER_B; + } else if (scope === "c") { + namespace = ctx.exports.CounterC; + } + if (!namespace) { + return new Response( + "Use /a/... or /b/... or /c/... to address a counter.", + { status: 400 } + ); + } + + const instance = url.searchParams.get("instance") ?? "default"; + const stub = namespace.getByName(instance); + + switch (action) { + case "": + case undefined: + return Response.json({ value: await stub.value() }); + case "increment": + return Response.json({ value: await stub.increment() }); + case "sqlite-check": + return Response.json({ ok: await stub.sqliteOk() }); + default: + return new Response("not found", { status: 404 }); + } + }, +}; diff --git a/fixtures/durable-objects-exports-app/tests/index.test.ts b/fixtures/durable-objects-exports-app/tests/index.test.ts new file mode 100644 index 0000000..97d5330 --- /dev/null +++ b/fixtures/durable-objects-exports-app/tests/index.test.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { join, resolve } from "node:path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { unstable_startWorker } from "wrangler"; + +const basePath = resolve(__dirname, ".."); + +describe("durable objects declared via the new `exports` config", () => { + let worker: Awaited<ReturnType<typeof unstable_startWorker>>; + const instance = randomUUID(); + + beforeAll(async () => { + worker = await unstable_startWorker({ + config: join(basePath, "wrangler.jsonc"), + }); + }); + + afterAll(async () => { + await worker?.dispose(); + }); + + it("starts CounterA at 0 and increments it via SQLite-backed storage", async ({ + expect, + }) => { + let response = await worker.fetch( + `http://example.com/a?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 0 }); + + response = await worker.fetch( + `http://example.com/a/increment?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 1 }); + + response = await worker.fetch( + `http://example.com/a/increment?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 2 }); + }); + + it("keeps CounterB independent of CounterA", async ({ expect }) => { + let response = await worker.fetch( + `http://example.com/b?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 0 }); + + response = await worker.fetch( + `http://example.com/b/increment?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 1 }); + + response = await worker.fetch(`http://example.com/a?instance=${instance}`); + expect(await response.json()).toEqual({ value: 2 }); + }); + + it("addresses unbound CounterC via `ctx.exports` (no binding required)", async ({ + expect, + }) => { + let response = await worker.fetch( + `http://example.com/c?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 0 }); + + response = await worker.fetch( + `http://example.com/c/increment?instance=${instance}` + ); + expect(await response.json()).toEqual({ value: 1 }); + + response = await worker.fetch(`http://example.com/a?instance=${instance}`); + expect(await response.json()).toEqual({ value: 2 }); + response = await worker.fetch(`http://example.com/b?instance=${instance}`); + expect(await response.json()).toEqual({ value: 1 }); + }); + + it('uses SQLite-backed storage (`storage: "sqlite"` from the `exports` map is honored in local dev)', async ({ + expect, + }) => { + for (const scope of ["a", "b", "c"]) { + const response = await worker.fetch( + `http://example.com/${scope}/sqlite-check?instance=${instance}` + ); + expect(await response.json()).toEqual({ ok: 1 }); + } + }); +}); diff --git a/fixtures/durable-objects-exports-app/tests/tsconfig.json b/fixtures/durable-objects-exports-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/durable-objects-exports-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/durable-objects-exports-app/tsconfig.json b/fixtures/durable-objects-exports-app/tsconfig.json new file mode 100644 index 0000000..1f38cb2 --- /dev/null +++ b/fixtures/durable-objects-exports-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + // `@cloudflare/workers-types/experimental` is required for the + // `ctx.exports` API surface and the `Cloudflare.GlobalProps` + // extension point that `wrangler types` populates. + "types": [ + "@cloudflare/workers-types/experimental", + "./worker-configuration.d.ts" + ] + }, + "include": ["src/**/*.ts", "worker-configuration.d.ts"] +} diff --git a/fixtures/durable-objects-exports-app/vitest.config.mts b/fixtures/durable-objects-exports-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/durable-objects-exports-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/durable-objects-exports-app/worker-configuration.d.ts b/fixtures/durable-objects-exports-app/worker-configuration.d.ts new file mode 100644 index 0000000..e1d094e --- /dev/null +++ b/fixtures/durable-objects-exports-app/worker-configuration.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 059202c799257c021e9b01439632a694) +interface __BaseEnv_Env { + COUNTER_A: DurableObjectNamespace<import("./src/index").CounterA>; + COUNTER_B: DurableObjectNamespace<import("./src/index").CounterB>; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + durableNamespaces: "CounterA" | "CounterB" | "CounterC"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/durable-objects-exports-app/wrangler.jsonc b/fixtures/durable-objects-exports-app/wrangler.jsonc new file mode 100644 index 0000000..d5c560f --- /dev/null +++ b/fixtures/durable-objects-exports-app/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + "name": "durable-objects-exports-app", + "main": "src/index.ts", + "compatibility_date": "2026-01-01", + "durable_objects": { + "bindings": [ + { "name": "COUNTER_A", "class_name": "CounterA" }, + { "name": "COUNTER_B", "class_name": "CounterB" }, + ], + }, + "exports": { + "CounterA": { "type": "durable-object", "storage": "sqlite" }, + "CounterB": { "type": "durable-object", "storage": "sqlite" }, + // CounterC is declared via `exports` only — no binding. + "CounterC": { "type": "durable-object", "storage": "sqlite" }, + "OldCounter": { "type": "durable-object", "state": "deleted" }, + "LegacyName": { + "type": "durable-object", + "state": "renamed", + "renamed_to": "CounterB", + }, + }, +} diff --git a/fixtures/dynamic-worker-loading/package.json b/fixtures/dynamic-worker-loading/package.json new file mode 100644 index 0000000..04c7d06 --- /dev/null +++ b/fixtures/dynamic-worker-loading/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fixture/dynamic-worker-loading", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "@cloudflare/workers-types": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/dynamic-worker-loading/src/index.ts b/fixtures/dynamic-worker-loading/src/index.ts new file mode 100644 index 0000000..cadfaa9 --- /dev/null +++ b/fixtures/dynamic-worker-loading/src/index.ts @@ -0,0 +1,34 @@ +export interface Env { + LOADER: { + get( + id: string, + factory: () => unknown + ): { + getEntrypoint(): Fetcher; + }; + }; +} + +export default { + async fetch(request: Request, env: Env): Promise<Response> { + const url = new URL(request.url); + + let worker = env.LOADER.get(url.pathname, () => { + return { + compatibilityDate: "2025-06-01", + + mainModule: "foo.js", + + modules: { + "foo.js": + "export default {\n" + + ` fetch(req, env, ctx) { return new Response('Hello with a dynamic worker loaded for ${url.pathname}'); }\n` + + "}\n", + }, + }; + }); + + let defaultEntrypoint = worker.getEntrypoint(); + return await defaultEntrypoint.fetch(request); + }, +}; diff --git a/fixtures/dynamic-worker-loading/tests/index.test.ts b/fixtures/dynamic-worker-loading/tests/index.test.ts new file mode 100644 index 0000000..0a70c43 --- /dev/null +++ b/fixtures/dynamic-worker-loading/tests/index.test.ts @@ -0,0 +1,36 @@ +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +describe("dynamic worker loading", () => { + const server = createTestHarness({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); + + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("should respond with response from dynamic worker", async ({ expect }) => { + let response = await server.fetch("/my-worker"); + let text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot( + `"Hello with a dynamic worker loaded for /my-worker"` + ); + }); + + it("should load different worker if ID changes", async ({ expect }) => { + let response = await server.fetch("/my-other-worker"); + let text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot( + `"Hello with a dynamic worker loaded for /my-other-worker"` + ); + }); +}); diff --git a/fixtures/dynamic-worker-loading/tests/tsconfig.json b/fixtures/dynamic-worker-loading/tests/tsconfig.json new file mode 100644 index 0000000..d2ce7f1 --- /dev/null +++ b/fixtures/dynamic-worker-loading/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts", "../../../node-types.d.ts"] +} diff --git a/fixtures/dynamic-worker-loading/tsconfig.json b/fixtures/dynamic-worker-loading/tsconfig.json new file mode 100644 index 0000000..2431bac --- /dev/null +++ b/fixtures/dynamic-worker-loading/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "types": ["@cloudflare/workers-types/experimental"], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/dynamic-worker-loading/vitest.config.mts b/fixtures/dynamic-worker-loading/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/dynamic-worker-loading/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/dynamic-worker-loading/wrangler.jsonc b/fixtures/dynamic-worker-loading/wrangler.jsonc new file mode 100644 index 0000000..76ebfc6 --- /dev/null +++ b/fixtures/dynamic-worker-loading/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "name": "dynamic-worker-loading", + "main": "src/index.ts", + "compatibility_date": "2023-05-04", + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], +} diff --git a/fixtures/email-worker/README.md b/fixtures/email-worker/README.md new file mode 100644 index 0000000..23cca67 --- /dev/null +++ b/fixtures/email-worker/README.md @@ -0,0 +1,119 @@ +# Email Worker Fixture + +This fixture demonstrates both the **EmailMessage API** (raw MIME) and the **MessageBuilder API** for sending emails in Cloudflare Workers. + +## Running Locally + +Start the development server: + +```bash +pnpm start +# or +wrangler dev +``` + +## Available Routes + +### EmailMessage API (Raw MIME) + +**`GET /send`** - Original API using manual MIME construction + +- Uses the `mimetext` library to build MIME messages +- Sends via `LIST_SEND` binding + +### MessageBuilder API + +**`GET /send-simple`** - Simple text-only email + +- Basic MessageBuilder example with plain text +- Demonstrates named sender: `{ name: "Alice", email: "..." }` + +**`GET /send-html`** - Email with text and HTML versions + +- Shows how to include both `text` and `html` content +- HTML includes inline CSS styling + +**`GET /send-attachment`** - Email with single text attachment + +- Demonstrates attaching a text file +- Uses `TextEncoder` to create content + +**`GET /send-multi-attachment`** - Email with multiple attachments + +- Includes three different attachment types: + - Text file (`.txt`) + - JSON file (`.json`) + - Binary file (simulated `.pdf`) +- Shows both text and HTML content + +**`GET /send-complex`** - Complex email with multiple recipients + +- Multiple TO recipients (with and without names) +- CC recipient with name +- BCC recipient (array) +- Both text and HTML content + +### Testing Bindings + +**`GET /test-bindings`** - Test all three email binding types + +- `UNBOUND_SEND` - No restrictions on recipients +- `SPECIFIC_SEND` - Only allows `something@example.com` +- `LIST_SEND` - Allows `something@example.com` and `else@example.com` + +## What to Expect + +When you send emails using these routes: + +1. **Console Output**: Miniflare logs details about the email being sent +2. **File Paths**: For MessageBuilder emails, you'll see temp file paths: + - Text content → `.txt` files + - HTML content → `.html` files + - Attachments → Files with their original extensions + +### Example Console Output + +``` +send_email binding called with MessageBuilder: +From: "Alice" <sender@penalosa.cloud> +To: else@example.com +Subject: Simple MessageBuilder Test + +Text: /var/folders/.../email-text/abc-123.txt +``` + +You can open these files in your editor or browser to inspect the email content. + +## Email Bindings Configuration + +This fixture has three email bindings configured in `wrangler.jsonc`: + +```jsonc +{ + "send_email": [ + { + "name": "UNBOUND_SEND", + // No restrictions + }, + { + "name": "SPECIFIC_SEND", + "destination_address": "something@example.com", + // Only allows sending to this specific address + }, + { + "name": "LIST_SEND", + "allowed_destination_addresses": [ + "something@example.com", + "else@example.com", + ], + // Only allows sending to addresses in this list + }, + ], +} +``` + +## Notes + +- Type assertions (`as any`) are used because `@cloudflare/workers-types` doesn't yet include MessageBuilder types +- At runtime in Miniflare, the MessageBuilder API works correctly +- The `allowed_sender_addresses` configuration is not included in this fixture, so any sender address is accepted diff --git a/fixtures/email-worker/package.json b/fixtures/email-worker/package.json new file mode 100644 index 0000000..cd850f5 --- /dev/null +++ b/fixtures/email-worker/package.json @@ -0,0 +1,14 @@ +{ + "name": "@fixture/worker-email", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "@types/mimetext": "^2.0.4", + "mimetext": "^3.0.27", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/email-worker/src/index.ts b/fixtures/email-worker/src/index.ts new file mode 100644 index 0000000..e9771b3 --- /dev/null +++ b/fixtures/email-worker/src/index.ts @@ -0,0 +1,255 @@ +import { EmailMessage } from "cloudflare:email"; +import { env, WorkerEntrypoint } from "cloudflare:workers"; +import { createMimeMessage } from "mimetext"; + +export default class extends WorkerEntrypoint<Env> { + async fetch(request: Request): Promise<Response> { + const url = new URL(request.url); + if (url.pathname === "/error") throw new Error("Hello Error"); + + // Original EmailMessage API (raw MIME) + if (url.pathname === "/send") { + const msg = createMimeMessage(); + msg.setSender({ name: "GPT-4", addr: "sender@penalosa.cloud" }); + msg.setRecipient("else@example.com"); + msg.setSubject("An email generated in a Worker"); + msg.addMessage({ + contentType: "text/plain", + data: "Congratulations, you just sent an email from a Worker.", + }); + const m = new EmailMessage( + "sender@penalosa.cloud", + "else@example.com", + msg.asRaw() + ); + await this.env.LIST_SEND.send(m); + return new Response( + "✅ EmailMessage sent! Check console for temp file path.\n" + ); + } + + // MessageBuilder API: Simple text-only email + if (url.pathname === "/send-simple") { + await this.env.LIST_SEND.send({ + from: { name: "Alice", email: "sender@penalosa.cloud" }, + to: "else@example.com", + subject: "Simple MessageBuilder Test", + text: "This is a plain text email using the MessageBuilder API!", + } as any); + return new Response( + "✅ Simple text email sent! Check console for temp file path.\n" + ); + } + + // MessageBuilder API: Email with both text and HTML + if (url.pathname === "/send-html") { + await this.env.LIST_SEND.send({ + from: { name: "Bob", email: "sender@penalosa.cloud" }, + to: "else@example.com", + subject: "HTML Email Test", + text: "This is the plain text version.", + html: ` +<!DOCTYPE html> +<html> +<head> + <style> + body { font-family: Arial, sans-serif; } + .highlight { color: #0066cc; font-weight: bold; } + </style> +</head> +<body> + <h1>Hello from MessageBuilder!</h1> + <p>This is the <span class="highlight">HTML</span> version.</p> + <ul> + <li>Feature 1</li> + <li>Feature 2</li> + <li>Feature 3</li> + </ul> +</body> +</html> + `.trim(), + } as any); + return new Response( + "✅ HTML email sent! Check console for text and HTML file paths.\n" + ); + } + + // MessageBuilder API: Email with text attachment + if (url.pathname === "/send-attachment") { + const textContent = new TextEncoder().encode( + "This is a sample text file attachment.\n\nLine 2\nLine 3\n" + ); + + await this.env.LIST_SEND.send({ + from: "sender@penalosa.cloud", + to: "else@example.com", + subject: "Email with Text Attachment", + text: "Please see the attached text file.", + attachments: [ + { + disposition: "attachment", + filename: "sample.txt", + type: "text/plain", + content: textContent, + }, + ], + } as any); + return new Response( + "✅ Email with text attachment sent! Check console for file paths.\n" + ); + } + + // MessageBuilder API: Email with multiple attachments (text, JSON, simulated binary) + if (url.pathname === "/send-multi-attachment") { + const textAttachment = new TextEncoder().encode("Sample text file."); + const jsonAttachment = new TextEncoder().encode( + JSON.stringify({ message: "Hello from JSON!", timestamp: Date.now() }) + ); + // Simulate a small binary file (e.g., a tiny "PDF" with some binary data) + const binaryAttachment = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0x25, 0xe2, 0xe3, + 0xcf, 0xd3, + ]); // "%PDF-1.4" header + + await this.env.LIST_SEND.send({ + from: { name: "Charlie", email: "sender@penalosa.cloud" }, + to: "else@example.com", + subject: "Email with Multiple Attachments", + text: "This email has three different types of attachments.", + html: "<h2>Multiple Attachments</h2><p>Check out the attached files!</p>", + attachments: [ + { + disposition: "attachment", + filename: "document.txt", + type: "text/plain", + content: textAttachment, + }, + { + disposition: "attachment", + filename: "data.json", + type: "application/json", + content: jsonAttachment, + }, + { + disposition: "attachment", + filename: "sample.pdf", + type: "application/pdf", + content: binaryAttachment, + }, + ], + } as any); + return new Response( + "✅ Email with multiple attachments sent! Check console for all file paths.\n" + ); + } + + // MessageBuilder API: Complex email with multiple recipients (to/cc/bcc) + if (url.pathname === "/send-complex") { + await this.env.LIST_SEND.send({ + from: { name: "David", email: "sender@penalosa.cloud" }, + to: [ + { name: "Recipient One", email: "else@example.com" }, + "something@example.com", + ], + cc: { name: "CC Person", email: "else@example.com" }, + bcc: ["something@example.com"], + subject: "Complex Email Test", + text: "Plain text for email clients that don't support HTML.", + html: ` +<html> +<body> + <h1>Complex Email</h1> + <p>This demonstrates:</p> + <ul> + <li>Multiple TO recipients (with and without names)</li> + <li>CC recipient with name</li> + <li>BCC recipient</li> + <li>Both text and HTML content</li> + </ul> +</body> +</html> + `.trim(), + } as any); + return new Response( + "✅ Complex email sent! Check console for file paths.\n" + ); + } + + // Test all three binding types + if (url.pathname === "/test-bindings") { + const results: string[] = []; + + // Test UNBOUND_SEND (no restrictions) + try { + await this.env.UNBOUND_SEND.send({ + from: "sender@penalosa.cloud", + to: "anyone@anywhere.com", + subject: "UNBOUND_SEND Test", + text: "This uses the unbound binding.", + } as any); + results.push("✅ UNBOUND_SEND: Success"); + } catch (e) { + results.push(`❌ UNBOUND_SEND: ${(e as Error).message}`); + } + + // Test SPECIFIC_SEND (only to something@example.com) + try { + await this.env.SPECIFIC_SEND.send({ + from: "sender@penalosa.cloud", + to: "something@example.com", + subject: "SPECIFIC_SEND Test", + text: "This uses the specific binding.", + } as any); + results.push("✅ SPECIFIC_SEND: Success"); + } catch (e) { + results.push(`❌ SPECIFIC_SEND: ${(e as Error).message}`); + } + + // Test LIST_SEND (allowed list) + try { + await this.env.LIST_SEND.send({ + from: "sender@penalosa.cloud", + to: "else@example.com", + subject: "LIST_SEND Test", + text: "This uses the list binding.", + } as any); + results.push("✅ LIST_SEND: Success"); + } catch (e) { + results.push(`❌ LIST_SEND: ${(e as Error).message}`); + } + + return new Response(results.join("\n") + "\n"); + } + + return new Response( + "Email Worker Fixture\n\n" + + "Available routes:\n" + + " /send - EmailMessage API (raw MIME)\n" + + " /send-simple - MessageBuilder: Simple text email\n" + + " /send-html - MessageBuilder: Text + HTML\n" + + " /send-attachment - MessageBuilder: With text attachment\n" + + " /send-multi-attachment - MessageBuilder: Multiple attachments\n" + + " /send-complex - MessageBuilder: Multiple recipients (to/cc/bcc)\n" + + " /test-bindings - Test all three email binding types\n" + ); + } + async email(message: ForwardableEmailMessage) { + console.log("hello"); + const msg = createMimeMessage(); + msg.setHeader("In-Reply-To", message.headers.get("Message-ID")!); + msg.setSender(message.to); + msg.setRecipient(message.from); + msg.setSubject("An email generated in a Worker"); + msg.addMessage({ + contentType: "text/plain", + data: `Congratulations, you just sent an email from a Worker.`, + }); + + const m = new EmailMessage(message.to, message.from, msg.asRaw()); + await message.forward( + "samuel@macleod.space", + new Headers({ hello: "world" }) + ); + await message.reply(m); + } +} diff --git a/fixtures/email-worker/tsconfig.json b/fixtures/email-worker/tsconfig.json new file mode 100644 index 0000000..2fd2e1b --- /dev/null +++ b/fixtures/email-worker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/email-worker/worker-configuration.d.ts b/fixtures/email-worker/worker-configuration.d.ts new file mode 100644 index 0000000..54fcf91 --- /dev/null +++ b/fixtures/email-worker/worker-configuration.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler by running `wrangler types --x-include-runtime` + +interface Env extends Env { + UNBOUND_SEND: SendEmail; + SPECIFIC_SEND: SendEmail; + LIST_SEND: SendEmail; +} diff --git a/fixtures/email-worker/wrangler.jsonc b/fixtures/email-worker/wrangler.jsonc new file mode 100644 index 0000000..dd9c211 --- /dev/null +++ b/fixtures/email-worker/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "email-to-rss", + "main": "src/index.ts", + "compatibility_date": "2025-02-14", + "compatibility_flags": ["nodejs_compat"], + "send_email": [ + { + "name": "UNBOUND_SEND", + }, + { + "name": "SPECIFIC_SEND", + "destination_address": "something@example.com", + }, + { + "name": "LIST_SEND", + "allowed_destination_addresses": [ + "something@example.com", + "else@example.com", + ], + }, + ], +} diff --git a/fixtures/entrypoints-rpc-tests/package.json b/fixtures/entrypoints-rpc-tests/package.json new file mode 100644 index 0000000..28d2177 --- /dev/null +++ b/fixtures/entrypoints-rpc-tests/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fixture/entrypoints-rpc", + "private": true, + "scripts": { + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "miniflare": "workspace:*", + "ts-dedent": "^2.2.0", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/entrypoints-rpc-tests/tests/entrypoints.spec.ts b/fixtures/entrypoints-rpc-tests/tests/entrypoints.spec.ts new file mode 100644 index 0000000..33b8c07 --- /dev/null +++ b/fixtures/entrypoints-rpc-tests/tests/entrypoints.spec.ts @@ -0,0 +1,951 @@ +import fs, { mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Miniflare } from "miniflare"; +import dedent from "ts-dedent"; +import { Agent, fetch, setGlobalDispatcher } from "undici"; +import { test as baseTest, describe, onTestFinished, vi } from "vitest"; +import { + runWranglerDev, + runWranglerPagesDev, +} from "../../shared/src/run-wrangler-long-lived"; + +const timeoutAgent = new Agent({ + connectTimeout: 2_000, + bodyTimeout: 2_000, + headersTimeout: 2_000, +}); +setGlobalDispatcher(timeoutAgent); + +const tmpPathBase = path.join(os.tmpdir(), "wrangler-tests"); + +type WranglerDevSession = Awaited<ReturnType<typeof runWranglerDev>>; +type StartDevSession = ( + files: Record<string, string>, + flags?: string[], + pagesPublicPath?: string +) => Promise<{ url: URL; session: WranglerDevSession }>; + +export async function seed(root: string, files: Record<string, string>) { + for (const [name, contents] of Object.entries(files)) { + const filePath = path.resolve(root, name); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); + } +} + +function waitFor<T>( + callback: Parameters<typeof vi.waitFor<T>>[0], + timeout = 5_000 +) { + // The default timeout of `vi.waitFor()` is only 1s, which is a little + // short for some of these tests, especially on Windows. + return vi.waitFor(callback, { timeout, interval: 250 }); +} + +const test = baseTest.extend<{ + tmpPath: string; + isolatedDevRegistryPath: string; + dev: StartDevSession; +}>({ + // Fixture for creating a temporary directory + async tmpPath({}, use) { + const tmpPath = await fs.realpath(await fs.mkdtemp(tmpPathBase)); + await use(tmpPath); + await fs.rm(tmpPath, { recursive: true, maxRetries: 10 }); + }, + // Fixture for starting an isolated dev registry server on a random port + async isolatedDevRegistryPath({}, use) { + const tmpPath = await fs.realpath(await fs.mkdtemp(tmpPathBase)); + await use(tmpPath); + await fs.rm(tmpPath, { recursive: true, maxRetries: 10 }); + }, + // Fixture for starting a worker in a temporary directory, using the test's + // isolated dev registry + async dev({ tmpPath, isolatedDevRegistryPath }, use) { + const workerTmpPathBase = path.join(tmpPath, "worker-"); + const cleanups: (() => Promise<unknown>)[] = []; + + const fn: StartDevSession = async (files, flags, pagesPublicPath) => { + const workerPath = await fs.mkdtemp(workerTmpPathBase); + await seed(workerPath, files); + + let session: WranglerDevSession; + if (pagesPublicPath !== undefined) { + session = await runWranglerPagesDev( + workerPath, + pagesPublicPath, + ["--port=0", "--inspector-port=0", ...(flags ?? [])], + { WRANGLER_REGISTRY_PATH: String(isolatedDevRegistryPath) } + ); + } else { + session = await runWranglerDev( + workerPath, + ["--port=0", "--inspector-port=0", ...(flags ?? [])], + { WRANGLER_REGISTRY_PATH: String(isolatedDevRegistryPath) } + ); + } + + cleanups.push(session.stop); + // noinspection HttpUrlsUsage + const url = new URL(`http://${session.ip}:${session.port}`); + return { url, session }; + }; + + await use(fn); + + await Promise.allSettled(cleanups.map((fn) => fn())); + }, +}); +describe("entrypoints", () => { + test("should support binding to the same worker", async ({ dev, expect }) => { + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "entry" + `, + "index.ts": dedent` + export default { + fetch(request, env, ctx) { + const { pathname } = new URL(request.url); + + if (pathname === "/loopback") { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + + return env.SERVICE.fetch("https://placeholder:9999/loopback", { + method: "POST", + cf: { thing: true }, + }); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + // Check protocol, host, and cf preserved + expect(await response.text()).toBe( + 'POST https://placeholder:9999/loopback {"thing":true}' + ); + }); + }); + + test("should support default ExportedHandler entrypoints", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + export default { + fetch(request, env, ctx) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + }; + `, + }); + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + `, + "index.ts": dedent` + export default { + fetch(request, env, ctx) { + return env.SERVICE.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + // Check protocol, host, and cf preserved + expect(text).toBe('POST https://placeholder:9999/ {"thing":true}'); + }); + }); + + test("should support default WorkerEntrypoint entrypoints", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + // Check middleware is transparent to RPC + export default class ThingEntrypoint extends WorkerEntrypoint { + fetch(request) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + ping() { + return "pong"; + } + }; + `, + }); + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + const response = await env.SERVICE.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + const text = await response.text(); + const pong = await env.SERVICE.ping(); + return new Response(\`\${text} \${pong}\`); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + // Check protocol, host, and cf preserved + expect(text).toBe('POST https://placeholder:9999/ {"thing":true} pong'); + }); + }); + + test("should support middleware with default WorkerEntrypoint entrypoints", async ({ + dev, + expect, + }) => { + const files: Record<string, string> = { + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "entry" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + let lastController; + export default class TestEntrypoint extends WorkerEntrypoint { + fetch(request) { + const { pathname } = new URL(request.url); + if (pathname === "/throw") throw new Error("Oops!"); + if (pathname === "/controller") return new Response(lastController.cron); + return new Response(\`\${request.method} \${new URL(request.url).pathname}\`); + } + scheduled(controller) { + lastController = controller; + } + } + `, + }; + const { url } = await dev(files, ["--test-scheduled"]); + + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe("GET /"); + }); + + // Check other events can be dispatched + let response = await fetch(new URL("/__scheduled?cron=* * * * 30", url)); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Ran scheduled event"); + response = await fetch(new URL("/controller", url)); + expect(response.status).toBe(200); + expect(await response.text()).toBe("* * * * 30"); + + // Check multiple middleware can be registered + response = await fetch(new URL("/throw", url)); + expect(response.status).toBe(500); + expect(response.headers.get("Content-Type")).toMatch(/text\/html/); + expect(await response.text()).toMatch("Oops!"); + }); + + test("should support named ExportedHandler entrypoints to itself", async ({ + dev, + expect, + }) => { + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "entry" + entrypoint = "ThingEntrypoint" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class ThingEntrypoint extends WorkerEntrypoint { + fetch(request) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + ping() { + return "pong"; + } + }; + export default { + fetch(request, env, ctx) { + return env.SERVICE.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + // Check protocol, host, and cf preserved + expect(await response.text()).toBe( + 'POST https://placeholder:9999/ {"thing":true}' + ); + }); + }); + + test("should support named ExportedHandler entrypoints", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + export const thing = { + fetch(request, env, ctx) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + entrypoint = "thing" + `, + "index.ts": dedent` + export default { + fetch(request, env, ctx) { + return env.SERVICE.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + // Check protocol, host, and cf preserved + expect(text).toBe('POST https://placeholder:9999/ {"thing":true}'); + }); + }); + + test("should support named WorkerEntrypoint entrypoints", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class ThingEntrypoint extends WorkerEntrypoint { + fetch(request) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + ping() { + return "pong"; + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + entrypoint = "ThingEntrypoint" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + const response = await env.SERVICE.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + const text = await response.text(); + const pong = await env.SERVICE.ping(); + return new Response(\`\${text} \${pong}\`); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + // Check protocol, host, and cf preserved + expect(text).toBe('POST https://placeholder:9999/ {"thing":true} pong'); + }); + }); + + test("should support named entrypoints in pages dev", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class ThingEntrypoint extends WorkerEntrypoint { + ping() { + return "pong"; + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + const files = { + "functions/index.ts": dedent` + export const onRequest = async ({ env }) => { + return new Response(await env.SERVICE.ping()); + }; + `, + }; + const { url } = await dev( + files, + ["--service=SERVICE=bound#ThingEntrypoint"], + /* pagesPublicPath */ "dist" + ); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + expect(text).toBe("pong"); + }); + }); + + test("should support co-dependent services", async ({ dev, expect }) => { + const { url } = await dev({ + "wrangler.toml": dedent` + name = "a" + main = "index.ts" + + [[services]] + binding = "SERVICE_B" + service = "b" + entrypoint = "BEntrypoint" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class AEntrypoint extends WorkerEntrypoint { + ping() { + return "a:pong"; + } + }; + export default { + async fetch(request, env, ctx) { + return new Response(await env.SERVICE_B.ping()); + } + }; + `, + }); + + await dev({ + "wrangler.toml": dedent` + name = "b" + main = "index.ts" + + [[services]] + binding = "SERVICE_A" + service = "a" + entrypoint = "AEntrypoint" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class BEntrypoint extends WorkerEntrypoint { + async ping() { + const result = await this.env.SERVICE_A.ping(); + return \`b:\${result}\`; + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + expect(text).toBe("b:a:pong"); + }); + }); + + test("should support binding to Durable Object in another worker", async ({ + dev, + expect, + }) => { + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + + [durable_objects] + bindings = [ + { name = "OBJECT", class_name = "ThingObject" } + ] + `, + "index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + export class ThingObject extends DurableObject { + fetch(request) { + return new Response(\`\${request.method} \${request.url} \${JSON.stringify(request.cf)}\`); + } + get property() { + return "property:ping"; + } + method() { + return "method:ping"; + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [durable_objects] + bindings = [ + { name = "OBJECT", class_name = "ThingObject", script_name = "bound" } + ] + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + const id = env.OBJECT.newUniqueId(); + const stub = env.OBJECT.get(id); + + const { pathname } = new URL(request.url); + if (pathname === "/rpc") { + const results = []; + results.push(await stub.property) + results.push(await stub.method()) + return Response.json(results.map(String)); + } + + return stub.fetch("https://placeholder:9999/", { + method: "POST", + cf: { thing: true }, + }); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + // Check protocol, host, and cf preserved + expect(text).toBe('POST https://placeholder:9999/ {"thing":true}'); + }); + + await waitFor(async () => { + const rpcResponse = await fetch(new URL("/rpc", url)); + const errors = await rpcResponse.json(); + expect(errors).toMatchInlineSnapshot(` + [ + "property:ping", + "method:ping", + ] + `); + }); + }); + + test("should support binding to Durable Object in same worker", async ({ + dev, + expect, + }) => { + // RPC is supported here though :) + + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [durable_objects] + bindings = [ + { name = "OBJECT", class_name = "ThingObject" } + ] + `, + "index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + export class ThingObject extends DurableObject { + ping() { + return "pong"; + } + }; + export default { + async fetch(request, env, ctx) { + const id = env.OBJECT.newUniqueId(); + const stub = env.OBJECT.get(id); + return new Response(await stub.ping()); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe("pong"); + }); + }); + + test("should support binding to Durable Object in same worker with explicit script_name", async ({ + dev, + expect, + }) => { + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [durable_objects] + bindings = [ + { name = "OBJECT", class_name = "ThingObject", script_name = "entry" } + ] + `, + "index.ts": dedent` + import { DurableObject } from "cloudflare:workers"; + export class ThingObject extends DurableObject { + ping() { + return "pong"; + } + }; + export default { + async fetch(request, env, ctx) { + const id = env.OBJECT.newUniqueId(); + const stub = env.OBJECT.get(id); + return new Response(await stub.ping()); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe("pong"); + }); + }); + + test("should throw if binding to named entrypoint exported by version of wrangler without entrypoints support", async ({ + dev, + isolatedDevRegistryPath, + expect, + }) => { + // Start entry worker first, so the server starts with a stubbed service not + // found binding + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + entrypoint = "ThingEntrypoint" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + return env.SERVICE.fetch("https://placeholder:9999/"); + } + } + `, + }); + await waitFor(async () => { + const response = await fetch(url); + expect(response.status).toBe(503); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + + await writeFile( + path.join(isolatedDevRegistryPath, "bound"), + JSON.stringify({ + protocol: "http", + mode: "local", + port: 0, + host: "localhost", + durableObjects: [], + durableObjectsHost: "localhost", + durableObjectsPort: 0, + // Intentionally omitting `entrypointAddresses` + }) + ); + + await waitFor(async () => { + let response = await fetch(url); + expect(response.status).toBe(503); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + }); + + test("should throw if wrangler session doesn't export expected entrypoint", async ({ + dev, + expect, + }) => { + // Start entry worker first, so the server starts with a stubbed service not + // found binding + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + entrypoint = "ThingEntrypoint" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + return env.SERVICE.fetch("https://placeholder:9999/"); + } + } + `, + }); + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + + // Start up the bound worker without the expected entrypoint + await dev({ + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class BadEntrypoint extends WorkerEntrypoint { + fetch(request) { + return new Response("bad"); + } + }; + export default {}; // Required to treat as modules format worker + `, + }); + + // Wait for error to be thrown + await waitFor(async () => { + let response = await fetch(url); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + }); + + test("should support binding to wrangler session listening on HTTPS", async ({ + dev, + expect, + }) => { + // Start entry worker first, so the server starts with a stubbed service not + // found binding + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + return env.SERVICE.fetch("http://placeholder/"); + } + } + `, + }); + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + + // Start up the bound worker using HTTPS + const files: Record<string, string> = { + "wrangler.toml": dedent` + name = "bound" + main = "index.ts" + `, + "index.ts": dedent` + export default { + fetch() { + return new Response("secure"); + } + }; + `, + }; + await dev(files, ["--local-protocol=https"]); + + await waitFor(async () => { + const response = await fetch(url); + const text = await response.text(); + expect(text).toBe("secure"); + }, 10_000); + }); + + test("should support binding to version of wrangler without entrypoints support over HTTPS", async ({ + dev, + isolatedDevRegistryPath, + expect, + }) => { + // Start entry worker first, so the server starts with a stubbed service not + // found binding + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + [[services]] + binding = "SERVICE" + service = "bound" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + return env.SERVICE.fetch('http://placeholder/'); + } + } + `, + }); + await waitFor(async () => { + const response = await fetch(url); + expect(await response.text()).toBe( + 'Worker "bound" not found. Make sure it is running locally.' + ); + }); + + const boundWorker = new Miniflare({ + name: "bound", + unsafeDevRegistryPath: isolatedDevRegistryPath, + compatibilityFlags: ["experimental"], + modules: true, + https: true, + script: ` + export default { + async fetch(request, env, ctx) { + return new Response("Hello from bound!"); + } + } + `, + // No direct sockets so that no entrypointAddresses will be registered + }); + onTestFinished(() => boundWorker.dispose()); + + await boundWorker.ready; + await waitFor(async () => { + let response = await fetch(url); + expect(await response.text()).toBe("Hello from bound!"); + }, 10_000); + }); + + test("should throw if performing RPC with session that hasn't started", async ({ + dev, + expect, + }) => { + const { url } = await dev({ + "wrangler.toml": dedent` + name = "entry" + main = "index.ts" + + [[services]] + binding = "SERVICE" + service = "bound" + entrypoint = "ThingEntrypoint" + `, + "index.ts": dedent` + export default { + async fetch(request, env, ctx) { + const errors = []; + try { await env.SERVICE.property; } catch (e) { errors.push(e); } + try { await env.SERVICE.method(); } catch (e) { errors.push(e); } + return Response.json(errors.map(String)); + } + } + `, + }); + + await waitFor(async () => { + const response = await fetch(url); + const errors = await response.json(); + expect(errors).toMatchInlineSnapshot(` + [ + "Error: Worker "bound" not found. Make sure it is running locally.", + "Error: Worker "bound" not found. Make sure it is running locally.", + ] + `); + }); + }); +}); diff --git a/fixtures/entrypoints-rpc-tests/tsconfig.json b/fixtures/entrypoints-rpc-tests/tsconfig.json new file mode 100644 index 0000000..2fd7b41 --- /dev/null +++ b/fixtures/entrypoints-rpc-tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "include": ["**/*.ts"] +} diff --git a/fixtures/entrypoints-rpc-tests/vitest.config.mts b/fixtures/entrypoints-rpc-tests/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/entrypoints-rpc-tests/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/entrypoints-rpc-tests/wrangler.jsonc b/fixtures/entrypoints-rpc-tests/wrangler.jsonc new file mode 100644 index 0000000..b75d756 --- /dev/null +++ b/fixtures/entrypoints-rpc-tests/wrangler.jsonc @@ -0,0 +1,4 @@ +{ + "main": "src/index.mjs", + "compatibility_date": "2023-12-01", +} diff --git a/fixtures/experimental-new-config/cloudflare.config.ts b/fixtures/experimental-new-config/cloudflare.config.ts new file mode 100644 index 0000000..936521a --- /dev/null +++ b/fixtures/experimental-new-config/cloudflare.config.ts @@ -0,0 +1,11 @@ +import { bindings, defineWorker } from "wrangler/experimental-config"; +import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + +export default defineWorker((ctx) => ({ + name: "experimental-new-config", + entrypoint, + compatibilityDate: "2026-05-18", + env: { + MY_TEXT: bindings.text(`The mode is ${ctx.mode}`), + }, +})); diff --git a/fixtures/experimental-new-config/package.json b/fixtures/experimental-new-config/package.json new file mode 100644 index 0000000..83bbda8 --- /dev/null +++ b/fixtures/experimental-new-config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/experimental-new-config", + "private": true, + "scripts": { + "build": "wrangler deploy --x-new-config --dry-run --outdir=dist", + "check:type": "tsc --build", + "deploy": "wrangler deploy --x-new-config", + "dev": "wrangler dev --x-new-config", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/shared": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/experimental-new-config/src/index.ts b/fixtures/experimental-new-config/src/index.ts new file mode 100644 index 0000000..ee695db --- /dev/null +++ b/fixtures/experimental-new-config/src/index.ts @@ -0,0 +1,7 @@ +import { env } from "cloudflare:workers"; + +export default { + fetch() { + return new Response(env.MY_TEXT); + }, +} satisfies ExportedHandler; diff --git a/fixtures/experimental-new-config/test/index.test.ts b/fixtures/experimental-new-config/test/index.test.ts new file mode 100644 index 0000000..f37cb96 --- /dev/null +++ b/fixtures/experimental-new-config/test/index.test.ts @@ -0,0 +1,210 @@ +import childProcess from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { removeDir } from "@fixture/shared/src/fs-helpers"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { + runWranglerDev, + wranglerEntryPath, +} from "../../shared/src/run-wrangler-long-lived"; + +const fixtureDir = path.resolve(__dirname, ".."); + +/** + * Spawn `wrangler` synchronously inside the given working directory. + */ +function spawnWrangler(cwd: string, args: string[]) { + return childProcess.spawnSync( + process.execPath, + [wranglerEntryPath, ...args], + { + cwd, + env: { + ...process.env, + WRANGLER_LOG_PATH: "", + NO_COLOR: "1", + FORCE_COLOR: "0", + }, + } + ); +} + +async function getTmpDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "wrangler-new-config-")); +} + +/** + * Stage the fixture in a temporary directory. Symlinks `node_modules` so + * the staged cloudflare.config.ts can resolve `wrangler/experimental-config`. + */ +async function stageFixture(): Promise<string> { + const tmp = await getTmpDir(); + for (const name of [ + "package.json", + "src", + "cloudflare.config.ts", + "wrangler.config.ts", + "worker-configuration.d.ts", + "tsconfig.json", + "tsconfig.node.json", + "tsconfig.worker.json", + ]) { + const srcPath = path.join(fixtureDir, name); + if (existsSync(srcPath)) { + await fs.cp(srcPath, path.join(tmp, name), { recursive: true }); + } + } + // Symlink the fixture's node_modules so package resolution works. + const fixtureNodeModules = path.join(fixtureDir, "node_modules"); + if (existsSync(fixtureNodeModules)) { + await fs.symlink(fixtureNodeModules, path.join(tmp, "node_modules"), "dir"); + } + return tmp; +} + +describe("--x-new-config deploy --dry-run", () => { + test("builds successfully and emits the worker bundle", async ({ + expect, + }) => { + const tmpDir = await stageFixture(); + try { + const outDir = path.join(tmpDir, "out"); + const result = spawnWrangler(tmpDir, [ + "deploy", + "--x-new-config", + "--dry-run", + `--outdir=${outDir}`, + ]); + expect(result.status, result.stderr.toString()).toBe(0); + expect(existsSync(path.join(outDir, "index.js"))).toBe(true); + } finally { + removeDir(tmpDir, { fireAndForget: true }); + } + }); + + test("rejects --config when used with --x-new-config", async ({ expect }) => { + const tmpDir = await stageFixture(); + try { + const result = spawnWrangler(tmpDir, [ + "deploy", + "--x-new-config", + "--config", + "./some-other.jsonc", + "--dry-run", + ]); + expect(result.status).not.toBe(0); + expect(result.stderr.toString()).toContain( + "--config is not supported with --experimental-new-config" + ); + } finally { + removeDir(tmpDir, { fireAndForget: true }); + } + }); + + test("rejects on out-of-scope commands (kv namespace list)", async ({ + expect, + }) => { + const tmpDir = await stageFixture(); + try { + const result = spawnWrangler(tmpDir, [ + "kv", + "namespace", + "list", + "--x-new-config", + ]); + expect(result.status).not.toBe(0); + // Yargs strict-mode rejection — the flag is only declared on the + // commands that support it, so yargs reports it as unknown elsewhere. + expect(result.stderr.toString()).toContain("Unknown arguments"); + expect(result.stderr.toString()).toContain("x-new-config"); + } finally { + removeDir(tmpDir, { fireAndForget: true }); + } + }); + + test("silently ignores adjacent wrangler.json", async ({ expect }) => { + const tmpDir = await stageFixture(); + try { + writeFileSync( + path.join(tmpDir, "wrangler.json"), + JSON.stringify({ + name: "should-be-ignored", + main: "src/does-not-exist.ts", + compatibility_date: "2020-01-01", + }) + ); + const outDir = path.join(tmpDir, "out"); + const result = spawnWrangler(tmpDir, [ + "deploy", + "--x-new-config", + "--dry-run", + `--outdir=${outDir}`, + ]); + // Should still succeed — `cloudflare.config.ts` is used; the + // `wrangler.json` is silently ignored. + expect(result.status, result.stderr.toString()).toBe(0); + expect(existsSync(path.join(outDir, "index.js"))).toBe(true); + } finally { + removeDir(tmpDir, { fireAndForget: true }); + } + }); + + test("--env staging surfaces in ctx.mode (bound text contains 'staging')", async ({ + expect, + }) => { + const tmpDir = await stageFixture(); + try { + const outDir = path.join(tmpDir, "out"); + const result = spawnWrangler(tmpDir, [ + "deploy", + "--x-new-config", + "--env", + "staging", + "--dry-run", + `--outdir=${outDir}`, + ]); + expect(result.status, result.stderr.toString()).toBe(0); + // The deploy --dry-run output prints the bound `MY_TEXT` value as + // part of the bindings table. With `--env staging`, the function- + // form `cloudflare.config.ts` evaluates `ctx.mode === "staging"`, so + // `bindings.text(`The mode is ${ctx.mode}`)` becomes + // `"The mode is staging"`. + const combined = result.stdout.toString() + result.stderr.toString(); + expect(combined).toContain("staging"); + } finally { + removeDir(tmpDir, { fireAndForget: true }); + } + }); +}); + +describe("--x-new-config dev", () => { + let tmpDir: string; + let stop: (() => Promise<unknown>) | undefined; + let ip: string; + let port: number; + + beforeAll(async () => { + tmpDir = await stageFixture(); + ({ ip, port, stop } = await runWranglerDev(tmpDir, [ + "--x-new-config", + "--env", + "dev", + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + removeDir(tmpDir, { fireAndForget: true }); + }); + + test("serves the correct response for a worker configured via cloudflare.config.ts", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/`); + expect(await response.text()).toBe("The mode is dev"); + }); +}); diff --git a/fixtures/experimental-new-config/tsconfig.json b/fixtures/experimental-new-config/tsconfig.json new file mode 100644 index 0000000..b52af70 --- /dev/null +++ b/fixtures/experimental-new-config/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ] +} diff --git a/fixtures/experimental-new-config/tsconfig.node.json b/fixtures/experimental-new-config/tsconfig.node.json new file mode 100644 index 0000000..78911ed --- /dev/null +++ b/fixtures/experimental-new-config/tsconfig.node.json @@ -0,0 +1,4 @@ +{ + "extends": ["@cloudflare/workers-tsconfig/base.json"], + "include": ["test"] +} diff --git a/fixtures/experimental-new-config/tsconfig.worker.json b/fixtures/experimental-new-config/tsconfig.worker.json new file mode 100644 index 0000000..60c89ac --- /dev/null +++ b/fixtures/experimental-new-config/tsconfig.worker.json @@ -0,0 +1,13 @@ +{ + "extends": ["@cloudflare/workers-tsconfig/worker.json"], + "compilerOptions": { + "allowImportingTsExtensions": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "include": [ + "src", + "cloudflare.config.ts", + "wrangler.config.ts", + "worker-configuration.d.ts" + ] +} diff --git a/fixtures/experimental-new-config/turbo.json b/fixtures/experimental-new-config/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/experimental-new-config/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/experimental-new-config/vitest.config.mts b/fixtures/experimental-new-config/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/experimental-new-config/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/experimental-new-config/worker-configuration.d.ts b/fixtures/experimental-new-config/worker-configuration.d.ts new file mode 100644 index 0000000..2e7a5f1 --- /dev/null +++ b/fixtures/experimental-new-config/worker-configuration.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Generated by @cloudflare/config +import type { InferEnv, InferDurableNamespaces, InferMainModule, UnwrapConfig } from "wrangler/experimental-config"; +import type Config from "./cloudflare.config"; + +type WorkerConfig = UnwrapConfig<typeof Config>; + +declare global { + namespace Cloudflare { + interface GlobalProps { + mainModule: InferMainModule<WorkerConfig>; + durableNamespaces: InferDurableNamespaces<WorkerConfig>; + } + interface Env extends InferEnv<WorkerConfig> {} + } + interface Env extends Cloudflare.Env {} +} diff --git a/fixtures/experimental-new-config/wrangler.config.ts b/fixtures/experimental-new-config/wrangler.config.ts new file mode 100644 index 0000000..92f77e9 --- /dev/null +++ b/fixtures/experimental-new-config/wrangler.config.ts @@ -0,0 +1,6 @@ +import { defineWranglerConfig } from "wrangler/experimental-config"; + +// Minimal empty form — exercises the optional case (no tooling overrides; +// defaults apply). Function form + non-empty configs are exercised by +// other unit tests in `packages/wrangler/src/__tests__/`. +export default defineWranglerConfig({}); diff --git a/fixtures/get-platform-proxy-remote-bindings/package.json b/fixtures/get-platform-proxy-remote-bindings/package.json new file mode 100644 index 0000000..6613796 --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixture/get-platform-proxy-remote-bindings", + "private": true, + "description": "A test for the getPlatformProxy utility used with remote bindings", + "scripts": { + "test:e2e": "vitest run --reporter=default", + "type:tests": "tsc --noEmit -p tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "miniflare": "workspace:*", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/get-platform-proxy-remote-bindings/remote-worker.js b/fixtures/get-platform-proxy-remote-bindings/remote-worker.js new file mode 100644 index 0000000..a88e54f --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/remote-worker.js @@ -0,0 +1,7 @@ +export default { + fetch() { + return new Response( + "Hello from a remote Worker part of the getPlatformProxy remote bindings fixture!" + ); + }, +}; diff --git a/fixtures/get-platform-proxy-remote-bindings/remote-worker.staging.js b/fixtures/get-platform-proxy-remote-bindings/remote-worker.staging.js new file mode 100644 index 0000000..94457a6 --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/remote-worker.staging.js @@ -0,0 +1,7 @@ +export default { + fetch() { + return new Response( + "Hello from a remote Worker, defined for the staging environment, part of the getPlatformProxy remote bindings fixture!" + ); + }, +}; diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts new file mode 100644 index 0000000..c0d8a50 --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts @@ -0,0 +1,360 @@ +import { execSync } from "child_process"; +import { randomUUID } from "crypto"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +/* eslint-disable workers-sdk/no-vitest-import-expect -- uses expect in helper functions and beforeAll */ +import { + afterAll, + assert, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +/* eslint-enable workers-sdk/no-vitest-import-expect */ +import { getPlatformProxy } from "wrangler"; +import type { KVNamespace } from "@cloudflare/workers-types/experimental"; +import type { DispatchFetch, Response } from "miniflare"; + +type Fetcher = { fetch: DispatchFetch }; + +const workersDomain = + process.env.E2E_ACCOUNT_WORKERS_DEV_DOMAIN ?? + "devprod-testing7928.workers.dev"; +const auth = getAuthenticatedEnv(); +const execOptions = { + encoding: "utf8", + env: { ...process.env, ...auth }, +} as const; +const remoteWorkerName = `preserve-e2e-get-platform-proxy-remote`; +const remoteStagingWorkerName = `preserve-e2e-get-platform-proxy-remote-staging`; +const remoteKvName = `tmp-e2e-kv${Date.now()}-test-remote-bindings-${randomUUID().split("-")[0]}`; + +const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + +if (auth) { + describe("getPlatformProxy - remote bindings", { timeout: 50_000 }, () => { + let remoteKvId: string; + + beforeAll(async () => { + const deployedUrl = `https://preserve-e2e-get-platform-proxy-remote.${workersDomain}/`; + + try { + assert((await fetch(deployedUrl)).status !== 404); + } catch (e) { + execSync( + `pnpm wrangler deploy remote-worker.js --name ${remoteWorkerName} --compatibility-date 2025-06-19`, + execOptions + ); + await vi.waitFor( + async () => { + const response = await fetch(deployedUrl); + expect(response.status).toBe(200); + }, + { timeout: 5000, interval: 500 } + ); + } + + const stagingDeployedUrl = `https://preserve-e2e-get-platform-proxy-remote-staging.${workersDomain}/`; + try { + assert((await fetch(stagingDeployedUrl)).status !== 404); + } catch { + execSync( + `pnpm wrangler deploy remote-worker.staging.js --name ${remoteStagingWorkerName} --compatibility-date 2025-06-19`, + execOptions + ); + await vi.waitFor( + async () => { + const response = await fetch(stagingDeployedUrl); + expect(response.status).toBe(200); + }, + { timeout: 5000, interval: 500 } + ); + } + + const kvAddOut = execSync( + `pnpm wrangler kv namespace create ${remoteKvName}`, + execOptions + ); + + const createdKvRegexMatch = kvAddOut.match(/"id": "(?<id>[^"]*?)"/); + const maybeRemoteKvId = createdKvRegexMatch?.groups?.["id"]; + assert(maybeRemoteKvId, `Failed to create remote kv ${remoteKvName}`); + remoteKvId = maybeRemoteKvId; + + execSync( + `pnpm wrangler kv key put test-key remote-kv-value --namespace-id=${remoteKvId} --remote`, + execOptions + ); + + rmSync("./.tmp", { recursive: true, force: true }); + mkdirSync("./.tmp"); + }, 35_000); + + afterAll(() => { + try { + execSync( + `pnpm wrangler kv namespace delete --namespace-id=${remoteKvId}`, + execOptions + ); + } catch {} + + try { + rmSync("./.tmp", { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + } catch {} + }, 35_000); + + beforeEach(() => { + errorSpy.mockReset(); + }); + + describe("normal usage", () => { + beforeAll(async () => { + mkdirSync("./.tmp/normal-usage"); + + writeFileSync( + "./.tmp/normal-usage/wrangler.json", + JSON.stringify( + { + name: "get-platform-proxy-fixture-test", + compatibility_date: "2025-06-01", + services: [ + { + binding: "MY_WORKER", + service: remoteWorkerName, + remote: true, + }, + ], + kv_namespaces: [ + { + binding: "MY_KV", + id: remoteKvId, + remote: true, + }, + ], + env: { + staging: { + services: [ + { + binding: "MY_WORKER", + service: remoteStagingWorkerName, + remote: true, + }, + ], + kv_namespaces: [ + { + binding: "MY_KV", + id: remoteKvId, + remote: true, + }, + ], + }, + }, + }, + undefined, + 2 + ), + "utf8" + ); + }); + + test("getPlatformProxy works with remote bindings", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", auth.CLOUDFLARE_ACCOUNT_ID); + vi.stubEnv("CLOUDFLARE_API_TOKEN", auth.CLOUDFLARE_API_TOKEN); + + const { env, dispose } = await getPlatformProxy<{ + MY_WORKER: Fetcher; + MY_KV: KVNamespace; + }>({ + configPath: "./.tmp/normal-usage/wrangler.json", + }); + + const response = await fetchFromWorker(env.MY_WORKER, "OK"); + const workerText = await response?.text(); + expect(workerText).toEqual( + "Hello from a remote Worker part of the getPlatformProxy remote bindings fixture!" + ); + + const kvValue = await env.MY_KV.get("test-key"); + expect(kvValue).toEqual("remote-kv-value"); + + await dispose(); + }); + + test("getPlatformProxy works with remote bindings specified in an environment", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", auth.CLOUDFLARE_ACCOUNT_ID); + vi.stubEnv("CLOUDFLARE_API_TOKEN", auth.CLOUDFLARE_API_TOKEN); + const { env, dispose } = await getPlatformProxy<{ + MY_WORKER: Fetcher; + MY_KV: KVNamespace; + }>({ + configPath: "./.tmp/normal-usage/wrangler.json", + environment: "staging", + }); + + const workerText = await ( + await env.MY_WORKER.fetch("http://example.com") + ).text(); + expect(workerText).toEqual( + "Hello from a remote Worker, defined for the staging environment, part of the getPlatformProxy remote bindings fixture!" + ); + + const kvValue = await env.MY_KV.get("test-key"); + expect(kvValue).toEqual("remote-kv-value"); + + await dispose(); + }); + }); + + describe("account id taken from the wrangler config", () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined); + vi.stubEnv("CLOUDFLARE_API_TOKEN", auth.CLOUDFLARE_API_TOKEN); + + test("usage with a wrangler config file with an invalid account id", async () => { + mkdirSync("./.tmp/config-with-invalid-account-id"); + + writeFileSync( + "./.tmp/config-with-invalid-account-id/wrangler.json", + JSON.stringify( + { + name: "get-platform-proxy-fixture-test", + account_id: "NOT a valid account id", + compatibility_date: "2025-06-01", + services: [ + { + binding: "MY_WORKER", + service: remoteWorkerName, + remote: true, + }, + ], + }, + undefined, + 2 + ), + "utf8" + ); + + await expect( + getPlatformProxy<{ + MY_WORKER: Fetcher; + }>({ + configPath: "./.tmp/config-with-invalid-account-id/wrangler.json", + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Failed to start the remote proxy session. Error reloading remote server: A request to the Cloudflare API (/accounts/NOT a valid account id/workers/subdomain/edge-preview) failed.]` + ); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect( + `${errorSpy.mock.calls?.[0]?.[0]}` + // Windows gets a different marker for ✘, so let's normalize it here + // so that this test can be platform independent + .replaceAll("✘", "X") + ).toMatchInlineSnapshot(` + "X [ERROR] A request to the Cloudflare API (/accounts/NOT a valid account id/workers/subdomain/edge-preview) failed. + + " + `); + }); + + test("usage with a wrangler config file with a valid account id", async () => { + mkdirSync("./.tmp/config-with-no-account-id"); + + writeFileSync( + "./.tmp/config-with-no-account-id/wrangler.json", + JSON.stringify( + { + name: "get-platform-proxy-fixture-test", + account_id: auth.CLOUDFLARE_ACCOUNT_ID, + compatibility_date: "2025-06-01", + services: [ + { + binding: "MY_WORKER", + service: remoteWorkerName, + remote: true, + }, + ], + }, + undefined, + 2 + ), + "utf8" + ); + + const { env, dispose } = await getPlatformProxy<{ + MY_WORKER: Fetcher; + }>({ + configPath: "./.tmp/config-with-no-account-id/wrangler.json", + }); + + const response = await fetchFromWorker(env.MY_WORKER, "OK"); + const workerText = await response?.text(); + expect(workerText).toEqual( + "Hello from a remote Worker part of the getPlatformProxy remote bindings fixture!" + ); + + await dispose(); + }); + }); + }); +} else { + test.skip("getPlatformProxy - remote bindings (no auth credentials)"); +} + +/** + * Tries to fetch from a worker multiple times until a response is returned which matches a specified + * statusText. Each fetch has a timeout signal making sure that it can't simply get stuck. + * + * This utility is used, instead of directly fetching from the Worker in order to prevent flakiness. + * + * @param worker The Worker to fetch from. + * @param expectedStatusText The response's expected statusText. + * @returns The successful Worker's response or null if no such response was obtained. + */ +async function fetchFromWorker( + worker: Fetcher, + expectedStatusText: string, + timeout = 30_000 +): Promise<Response | undefined> { + return vi.waitFor( + async () => { + try { + const response = await worker.fetch("http://example.com", { + signal: AbortSignal.timeout(5_000), + }); + expect(response.statusText).toEqual(expectedStatusText); + return response; + } catch {} + }, + { timeout, interval: 500 } + ); +} + +/** + * Gets an env object containing Cloudflare credentials or undefined if not authenticated. + * + * In the Github actions we convert the TEST_CLOUDFLARE_ACCOUNT_ID and TEST_CLOUDFLARE_API_TOKEN env variables. + * In local development we can rely on CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN env variables directly. + */ +function getAuthenticatedEnv() { + const CLOUDFLARE_ACCOUNT_ID = + process.env.TEST_CLOUDFLARE_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID; + const CLOUDFLARE_API_TOKEN = + process.env.TEST_CLOUDFLARE_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN; + + if (CLOUDFLARE_ACCOUNT_ID && CLOUDFLARE_API_TOKEN) { + return { + CLOUDFLARE_API_TOKEN, + CLOUDFLARE_ACCOUNT_ID, + }; + } + console.warn( + "Skipping vitest-pool-workers remote bindings tests because the environment is not authenticated with Cloudflare." + ); +} diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/remote-bindings-false.test.ts b/fixtures/get-platform-proxy-remote-bindings/tests/remote-bindings-false.test.ts new file mode 100644 index 0000000..a60dff2 --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/tests/remote-bindings-false.test.ts @@ -0,0 +1,24 @@ +import { describe, test } from "vitest"; +import { getPlatformProxy } from "wrangler"; +import type { Ai } from "@cloudflare/workers-types/experimental"; + +describe("getPlatformProxy - remote bindings with remoteBindings: false", () => { + test("getPlatformProxy works with remote bindings", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<{ + AI: Ai; + }>({ + configPath: "./wrangler.remote-bindings-false.jsonc", + remoteBindings: false, + }); + + await expect( + env.AI.run("@cf/meta/llama-3.1-8b-instruct-fp8", { + messages: [], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Binding AI needs to be run remotely]` + ); + + await dispose(); + }); +}); diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/tsconfig.json b/fixtures/get-platform-proxy-remote-bindings/tests/tsconfig.json new file mode 100644 index 0000000..a8480e7 --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/fixtures/get-platform-proxy-remote-bindings/turbo.json b/fixtures/get-platform-proxy-remote-bindings/turbo.json new file mode 100644 index 0000000..57926cd --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/turbo.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test:e2e": { + "env": [ + "$TURBO_EXTENDS$", + "VITEST", + "MINIFLARE_WORKERD_PATH", + "WRANGLER", + "WRANGLER_IMPORT", + "MINIFLARE_IMPORT", + "TEST_CLOUDFLARE_ACCOUNT_ID", + "TEST_CLOUDFLARE_API_TOKEN", + "WRANGLER_E2E_TEST_FILE" + ] + } + } +} diff --git a/fixtures/get-platform-proxy-remote-bindings/wrangler.remote-bindings-false.jsonc b/fixtures/get-platform-proxy-remote-bindings/wrangler.remote-bindings-false.jsonc new file mode 100644 index 0000000..2bb0fec --- /dev/null +++ b/fixtures/get-platform-proxy-remote-bindings/wrangler.remote-bindings-false.jsonc @@ -0,0 +1,8 @@ +{ + "name": "get-platform-proxy-remote-bindings-with-local-bindings-only-test", + "compatibility_date": "2025-05-07", + "ai": { + "binding": "AI", + "remote": true, + }, +} diff --git a/fixtures/get-platform-proxy/custom-toml/path/test.toml b/fixtures/get-platform-proxy/custom-toml/path/test.toml new file mode 100644 index 0000000..ef7778b --- /dev/null +++ b/fixtures/get-platform-proxy/custom-toml/path/test.toml @@ -0,0 +1,7 @@ +name = "get-bindings-proxy-fixture" +main = "src/index.ts" +compatibility_date = "2023-11-21" + +[vars] +MY_VAR = "my-var-value-from-a-custom-toml" +MY_JSON_VAR = { test = true, customToml = true } diff --git a/fixtures/get-platform-proxy/package.json b/fixtures/get-platform-proxy/package.json new file mode 100644 index 0000000..f254c9d --- /dev/null +++ b/fixtures/get-platform-proxy/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/get-platform-proxy", + "private": true, + "description": "A test for the getPlatformProxy utility", + "scripts": { + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc --noEmit -p tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/jest-image-snapshot": "^6.4.0", + "jest-image-snapshot": "^6.5.1", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/get-platform-proxy/public/test.txt b/fixtures/get-platform-proxy/public/test.txt new file mode 100644 index 0000000..2dd981b --- /dev/null +++ b/fixtures/get-platform-proxy/public/test.txt @@ -0,0 +1 @@ +this is a test text file! diff --git a/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png b/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png new file mode 100644 index 0000000..b53eecd Binary files /dev/null and b/fixtures/get-platform-proxy/tests/__image_snapshots__/get-platform-proxy-env-test-ts-get-platform-proxy-env-correctly-obtains-functioning-image-bindings-2-snap.png differ diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.caches.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.caches.test.ts new file mode 100644 index 0000000..c9aea3a --- /dev/null +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.caches.test.ts @@ -0,0 +1,71 @@ +import { Request, Response } from "undici"; +import { describe, it } from "vitest"; +import { getPlatformProxy } from "./shared"; + +describe("getPlatformProxy - caches", () => { + (["default", "named"] as const).forEach((cacheType) => + it(`correctly obtains a no-op ${cacheType} cache`, async ({ expect }) => { + const { caches, dispose } = await getPlatformProxy(); + try { + const cache = + cacheType === "default" + ? caches.default + : await caches.open("my-cache"); + + let match = await cache.match("http://0.0.0.0/test"); + expect(match).toBeUndefined(); + + const req = new Request("http://0.0.0.0/test"); + await cache.put(req, new Response("test")); + + const resp = await cache.match(req); + expect(resp).toBeUndefined(); + + const deleted = await cache.delete(req); + expect(deleted).toBe(false); + } finally { + await dispose(); + } + }) + ); + + it("should match the production runtime caches object", async ({ + expect, + }) => { + const { caches: platformProxyCaches, dispose } = await getPlatformProxy(); + const caches = platformProxyCaches as any; + try { + expect(Object.keys(caches)).toEqual(["default"]); + + expect(() => { + caches.has("my-cache"); + }).toThrow( + "Failed to execute 'has' on 'CacheStorage': the method is not implemented." + ); + + expect(() => { + caches.delete("my-cache"); + }).toThrow( + "Failed to execute 'delete' on 'CacheStorage': the method is not implemented." + ); + + expect(() => { + caches.keys(); + }).toThrow( + "Failed to execute 'keys' on 'CacheStorage': the method is not implemented." + ); + + expect(() => { + caches.match(new URL("https://localhost")); + }).toThrow( + "Failed to execute 'match' on 'CacheStorage': the method is not implemented." + ); + + expect(() => { + caches.nonExistentMethod(); + }).toThrow("caches.nonExistentMethod is not a function"); + } finally { + await dispose(); + } + }); +}); diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.cf.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.cf.test.ts new file mode 100644 index 0000000..d117df7 --- /dev/null +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.cf.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "vitest"; +import { getPlatformProxy } from "./shared"; + +describe("getPlatformProxy - cf", () => { + it("should provide mock data", async ({ expect }) => { + const { cf, dispose } = await getPlatformProxy(); + try { + expect(cf).toMatchObject({ + colo: "DFW", + city: "Austin", + regionCode: "TX", + }); + } finally { + await dispose(); + } + }); + + it("should match the production runtime cf object", async ({ expect }) => { + const { cf, dispose } = await getPlatformProxy(); + try { + expect(cf.constructor.name).toBe("Object"); + + expect(() => { + cf.city = "test city"; + }).toThrow( + "Cannot assign to read only property 'city' of object '#<Object>'" + ); + expect(cf.city).not.toBe("test city"); + + expect(() => { + cf.newField = "test new field"; + }).toThrow("Cannot add property newField, object is not extensible"); + expect("newField" in cf).toBe(false); + + expect(cf.botManagement).toMatchObject({ + score: 99, + }); + expect(Object.isFrozen(cf.botManagement)).toBe(true); + } finally { + await dispose(); + } + }); +}); diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.ctx.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.ctx.test.ts new file mode 100644 index 0000000..6e81128 --- /dev/null +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.ctx.test.ts @@ -0,0 +1,148 @@ +import { describe, it } from "vitest"; +import { getPlatformProxy } from "./shared"; + +describe("getPlatformProxy - ctx", () => { + it("should provide a no-op waitUntil method", async ({ expect }) => { + const { ctx, dispose } = await getPlatformProxy(); + try { + let value = 4; + ctx.waitUntil( + new Promise((resolve) => { + value++; + resolve(value); + }) + ); + expect(value).toBe(5); + } finally { + await dispose(); + } + }); + + it("should provide a no-op passThroughOnException method", async ({ + expect, + }) => { + const { ctx, dispose } = await getPlatformProxy(); + try { + expect(ctx.passThroughOnException()).toBe(undefined); + } finally { + await dispose(); + } + }); + + it("should match the production runtime ctx object", async ({ expect }) => { + const { ctx, dispose } = await getPlatformProxy(); + try { + expect(ctx.constructor.name).toBe("ExecutionContext"); + expect(typeof ctx.waitUntil).toBe("function"); + expect(typeof ctx.passThroughOnException).toBe("function"); + expect(ctx.props).toEqual({}); + + ctx.waitUntil = ((str: string) => `- ${str} -`) as any; + expect(ctx.waitUntil("waitUntil can be overridden" as any)).toBe( + "- waitUntil can be overridden -" + ); + + ctx.passThroughOnException = ((str: string) => `_ ${str} _`) as any; + expect( + (ctx.passThroughOnException as any)( + "passThroughOnException can be overridden" + ) + ).toBe("_ passThroughOnException can be overridden _"); + + (ctx as any).text = "the ExecutionContext can be extended"; + expect((ctx as any).text).toBe("the ExecutionContext can be extended"); + } finally { + await dispose(); + } + }); + + describe("detached methods should behave like workerd", () => { + it("destructured methods should throw illegal invocation errors", async ({ + expect, + }) => { + const { + ctx: { waitUntil, passThroughOnException }, + dispose, + } = await getPlatformProxy(); + try { + expect(() => { + waitUntil(new Promise(() => {})); + }).toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException(); + }).toThrow("Illegal invocation"); + } finally { + await dispose(); + } + }); + + it("extracted methods should throw illegal invocation errors", async ({ + expect, + }) => { + const { ctx, dispose } = await getPlatformProxy(); + const waitUntil = ctx.waitUntil; + const passThroughOnException = ctx.passThroughOnException; + + try { + expect(() => { + waitUntil(new Promise(() => {})); + }).toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException(); + }).toThrow("Illegal invocation"); + } finally { + await dispose(); + } + }); + + it("extracted methods which correctly bind this should not throw illegal invocation errors", async ({ + expect, + }) => { + const { ctx, dispose } = await getPlatformProxy(); + const waitUntil = ctx.waitUntil.bind(ctx); + const passThroughOnException = ctx.passThroughOnException; + + try { + expect(() => { + waitUntil(new Promise(() => {})); + }).not.toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException.apply(ctx, []); + }).not.toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException.call(ctx); + }).not.toThrow("Illegal invocation"); + } finally { + await dispose(); + } + }); + + it("extracted methods which incorrectly bind this should throw illegal invocation errors", async ({ + expect, + }) => { + const { ctx, dispose } = await getPlatformProxy(); + const waitUntil = ctx.waitUntil.bind({}); + const passThroughOnException = ctx.passThroughOnException; + + try { + expect(() => { + waitUntil(new Promise(() => {})); + }).toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException.apply(5, []); + }).toThrow("Illegal invocation"); + + expect(() => { + passThroughOnException.call(new Boolean()); + }).toThrow("Illegal invocation"); + } finally { + await dispose(); + } + }); + }); +}); diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts new file mode 100644 index 0000000..a6e02e0 --- /dev/null +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts @@ -0,0 +1,384 @@ +import path from "path"; +import { D1Database, R2Bucket } from "@cloudflare/workers-types"; +import { toMatchImageSnapshot } from "jest-image-snapshot"; +import { beforeEach, describe, it, MockInstance, vi } from "vitest"; +import { getPlatformProxy } from "./shared"; +import type { + Fetcher, + Hyperdrive, + ImagesBinding, + KVNamespace, + Workflow, +} from "@cloudflare/workers-types"; + +type Env = { + MY_VAR: string; + MY_VAR_A: string; + MY_JSON_VAR: Object; + MY_DEV_VAR: string; + MY_KV: KVNamespace; + MY_KV_PROD: KVNamespace; + MY_BUCKET: R2Bucket; + MY_D1: D1Database; + MY_HYPERDRIVE: Hyperdrive; + ASSETS: Fetcher; + IMAGES: ImagesBinding; + MY_WORKFLOW_INTERNAL: Workflow; + MY_WORKFLOW_EXTERNAL: Workflow; +}; + +const wranglerConfigFilePath = path.join(__dirname, "..", "wrangler.jsonc"); + +describe("getPlatformProxy - env", () => { + let warn = {} as MockInstance<typeof console.warn>; + + beforeEach(() => { + // Hide stdout messages from the test logs + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warn.mockClear(); + }); + + describe("var bindings", () => { + it("correctly obtains var bindings from both wrangler config and .dev.vars", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_VAR, MY_JSON_VAR, MY_DEV_VAR } = env; + expect(MY_VAR).toEqual("my-var-value"); + expect(MY_JSON_VAR).toEqual({ + test: true, + }); + expect(MY_DEV_VAR).toEqual("my-dev-var-value"); + } finally { + await dispose(); + } + }); + + it("correctly makes vars from .dev.vars override the ones in wrangler config", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_VAR_A } = env; + expect(MY_VAR_A).not.toEqual("my-var-a"); // if this fails, the value was read from wrangler config – not .dev.vars + expect(MY_VAR_A).toEqual("my-dev-var-a"); + } finally { + await dispose(); + } + }); + + it("correctly makes vars from .dev.vars not override bindings of the same name from wrangler config", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_KV } = env; + expect(MY_KV).not.toEqual("my-dev-kv"); + ["get", "delete", "list", "put", "getWithMetadata"].every( + (methodName) => + expect( + typeof (MY_KV as unknown as Record<string, unknown>)[methodName] + ).toBe("function") + ); + } finally { + await dispose(); + } + }); + + it("correctly reads a toml from a custom path alongside with its .dev.vars", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: path.join( + __dirname, + "..", + "custom-toml", + "path", + "test.toml" + ), + }); + try { + const { MY_VAR, MY_JSON_VAR, MY_DEV_VAR } = env; + expect(MY_VAR).toEqual("my-var-value-from-a-custom-toml"); + expect(MY_JSON_VAR).toEqual({ + test: true, + customToml: true, + }); + expect(MY_DEV_VAR).toEqual("my-dev-var-value-from-a-custom-location"); + } finally { + await dispose(); + } + }); + }); + + it("correctly reads a json config file", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: path.join(__dirname, "..", "wrangler.json"), + }); + try { + const { MY_VAR, MY_JSON_VAR } = env; + expect(MY_VAR).toEqual("my-var-value-from-a-json-config-file"); + expect(MY_JSON_VAR).toEqual({ + test: true, + fromJson: true, + }); + } finally { + await dispose(); + } + }); + + it("correctly obtains functioning ASSETS bindings", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + const res = await env.ASSETS.fetch("https://0.0.0.0/test.txt"); + const text = await res.text(); + expect(text).toEqual("this is a test text file!\n"); + await dispose(); + }); + + it("correctly obtains functioning KV bindings", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + const { MY_KV } = env; + let numOfKeys = (await MY_KV.list()).keys.length; + expect(numOfKeys).toBe(0); + await MY_KV.put("my-key", "my-value"); + numOfKeys = (await MY_KV.list()).keys.length; + expect(numOfKeys).toBe(1); + const value = await MY_KV.get("my-key"); + expect(value).toBe("my-value"); + await dispose(); + }); + + it("correctly obtains functioning R2 bindings", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_BUCKET } = env; + let numOfObjects = (await MY_BUCKET.list()).objects.length; + expect(numOfObjects).toBe(0); + await MY_BUCKET.put("my-object", "my-value"); + numOfObjects = (await MY_BUCKET.list()).objects.length; + expect(numOfObjects).toBe(1); + const value = await MY_BUCKET.get("my-object"); + expect(await value?.text()).toBe("my-value"); + } finally { + await dispose(); + } + }); + + it("correctly obtains functioning D1 bindings", async ({ expect }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_D1 } = env; + await MY_D1.exec( + `CREATE TABLE IF NOT EXISTS users ( id integer PRIMARY KEY AUTOINCREMENT, name text NOT NULL )` + ); + const stmt = MY_D1.prepare("insert into users (name) values (?1)"); + await MY_D1.batch([ + stmt.bind("userA"), + stmt.bind("userB"), + stmt.bind("userC"), + ]); + const { results } = await MY_D1.prepare( + "SELECT name FROM users LIMIT 5" + ).all(); + expect(results).toEqual([ + { name: "userA" }, + { name: "userB" }, + { name: "userC" }, + ]); + } finally { + await dispose(); + } + }); + + it("correctly obtains functioning Image bindings", async ({ expect }) => { + expect.extend({ toMatchImageSnapshot }); + + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { IMAGES } = env; + const streams = ( + await fetch("https://playground.devprod.cloudflare.dev/flares.png") + ).body!.tee(); + + // @ts-expect-error The stream types aren't matching up properly? + expect(await IMAGES.info(streams[0])).toMatchInlineSnapshot(` + { + "fileSize": 96549, + "format": "image/png", + "height": 1145, + "width": 2048, + } + `); + + // @ts-expect-error The stream types aren't matching up properly? + const response = await env.IMAGES.input(streams[1]) + .transform({ rotate: 90 }) + .transform({ width: 128, height: 100 }) + .transform({ blur: 20 }) + .output({ format: "image/png" }); + + expect( + Buffer.from(await response.response().arrayBuffer()) + ).toMatchImageSnapshot(); + } finally { + await dispose(); + } + }); + + // Important: the hyperdrive values are passthrough ones since the workerd specific hyperdrive values only make sense inside + // workerd itself and would simply not work in a node.js process + it("correctly obtains passthrough Hyperdrive bindings", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + const { MY_HYPERDRIVE } = env; + expect(MY_HYPERDRIVE.connectionString).toEqual( + "postgres://user:pass@127.0.0.1:1234/db" + ); + expect(MY_HYPERDRIVE.database).toEqual("db"); + expect(MY_HYPERDRIVE.host).toEqual("127.0.0.1"); + expect(MY_HYPERDRIVE.user).toEqual("user"); + expect(MY_HYPERDRIVE.password).toEqual("pass"); + expect(MY_HYPERDRIVE.port).toEqual(1234); + } finally { + await dispose(); + } + }); + + describe("DO warnings", () => { + it("warns about internal DOs and doesn't crash", async ({ expect }) => { + await getPlatformProxy<Env>({ + configPath: path.join(__dirname, "..", "wrangler_internal_do.jsonc"), + }); + expect(warn.mock.calls[0][0].replaceAll(/[\r\n]+/g, "\n")) + .toMatchInlineSnapshot(` + "▲ [WARNING]  You have defined bindings to the following internal Durable Objects: + - {"class_name":"MyDurableObject","name":"MY_DURABLE_OBJECT"} + These will not work in local development, but they should work in production. + + If you want to develop these locally, you can define your DO in a separate Worker, with a separate configuration file. + For detailed instructions, refer to the Durable Objects section here: https://developers.cloudflare.com/workers/wrangler/api#supported-bindings + " + `); + }); + + it("doesn't warn about external DOs and doesn't crash", async ({ + expect, + }) => { + await getPlatformProxy<Env>({ + configPath: path.join(__dirname, "..", "wrangler_external_do.jsonc"), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns only about internal Workflows, not cross-script ones, and doesn't crash", async ({ + expect, + }) => { + await getPlatformProxy<Env>({ + configPath: path.join(__dirname, "..", "wrangler_workflow.jsonc"), + }); + // Only MY_WORKFLOW_INTERNAL (no script_name) is unsupported in + // getPlatformProxy(); MY_WORKFLOW_EXTERNAL (script_name set) is + // passed through to miniflare, which routes it via the dev-registry + // proxy to the named worker. + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0].replaceAll(/[\r\n]+/g, "\n")) + .toMatchInlineSnapshot(` + "▲ [WARNING] You have defined bindings to the following Workflows without a script_name: + - {"binding":"MY_WORKFLOW_INTERNAL","name":"my-workflow-internal","class_name":"MyWorkflowInternal"} + These are not available in local development, so you will not be able to bind to them when testing locally, but they should work in production. + " + `); + }); + }); + + describe("with a target environment", () => { + it("should provide bindings targeting a specified environment and also inherit top-level ones", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + environment: "production", + }); + try { + expect(env.MY_VAR).not.toBe("my-var-value"); + expect(env.MY_VAR).toBe("my-PRODUCTION-var-value"); + expect(env.MY_JSON_VAR).toEqual({ test: true, production: true }); + + expect(env.MY_KV).toBeTruthy(); + expect(env.MY_KV_PROD).toBeTruthy(); + } finally { + await dispose(); + } + }); + + it("should not provide bindings targeting an environment when none was specified", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + }); + try { + expect(env.MY_VAR).not.toBe("my-PRODUCTION-var-value"); + expect(env.MY_VAR).toBe("my-var-value"); + expect(env.MY_JSON_VAR).toEqual({ test: true }); + + expect(env.MY_KV).toBeTruthy(); + expect(env.MY_KV_PROD).toBeFalsy(); + } finally { + await dispose(); + } + }); + + it("should provide secrets targeting a specified environment", async ({ + expect, + }) => { + const { env, dispose } = await getPlatformProxy<Env>({ + configPath: wranglerConfigFilePath, + environment: "production", + }); + try { + const { MY_DEV_VAR } = env; + expect(MY_DEV_VAR).not.toEqual("my-dev-var-value"); + expect(MY_DEV_VAR).toEqual("my-PRODUCTION-dev-var-value"); + } finally { + await dispose(); + } + }); + + it("should error if a non-existent environment is provided", async ({ + expect, + }) => { + await expect( + getPlatformProxy({ + configPath: wranglerConfigFilePath, + environment: "non-existent-environment", + }) + ).rejects.toThrow( + /No environment found in configuration with name "non-existent-environment"/ + ); + }); + }); +}); diff --git a/fixtures/get-platform-proxy/tests/shared.ts b/fixtures/get-platform-proxy/tests/shared.ts new file mode 100644 index 0000000..1115643 --- /dev/null +++ b/fixtures/get-platform-proxy/tests/shared.ts @@ -0,0 +1,13 @@ +import { getPlatformProxy as originalGetPlatformProxy } from "wrangler"; +import type { GetPlatformProxyOptions } from "wrangler"; + +// Here we wrap the actual original getPlatformProxy function and disable its persistance, this is to make sure +// that we don't implement any persistance during these tests (which would add unnecessary extra complexity) +export function getPlatformProxy<T>( + options: Omit<GetPlatformProxyOptions, "persist"> = {} +): ReturnType<typeof originalGetPlatformProxy<T>> { + return originalGetPlatformProxy({ + ...options, + persist: false, + }); +} diff --git a/fixtures/get-platform-proxy/tests/tsconfig.json b/fixtures/get-platform-proxy/tests/tsconfig.json new file mode 100644 index 0000000..a8480e7 --- /dev/null +++ b/fixtures/get-platform-proxy/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/fixtures/get-platform-proxy/vitest.config.mts b/fixtures/get-platform-proxy/vitest.config.mts new file mode 100644 index 0000000..48307cd --- /dev/null +++ b/fixtures/get-platform-proxy/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + reporters: ["default"], + testTimeout: 25_000, + hookTimeout: 25_000, + teardownTimeout: 25_000, + useAtomics: true, + }, +}); diff --git a/fixtures/get-platform-proxy/wrangler.json b/fixtures/get-platform-proxy/wrangler.json new file mode 100644 index 0000000..092b751 --- /dev/null +++ b/fixtures/get-platform-proxy/wrangler.json @@ -0,0 +1,11 @@ +{ + "name": "get-bindings-proxy-fixture", + "main": "src/index.ts", + "vars": { + "MY_VAR": "my-var-value-from-a-json-config-file", + "MY_JSON_VAR": { + "test": true, + "fromJson": true + } + } +} diff --git a/fixtures/get-platform-proxy/wrangler.jsonc b/fixtures/get-platform-proxy/wrangler.jsonc new file mode 100644 index 0000000..eb1e8d6 --- /dev/null +++ b/fixtures/get-platform-proxy/wrangler.jsonc @@ -0,0 +1,64 @@ +{ + "name": "get-bindings-proxy-fixture", + "main": "src/index.ts", + "compatibility_date": "2023-11-21", + "assets": { + "directory": "./public", + "binding": "ASSETS", + "html_handling": "auto-trailing-slash", + "not_found_handling": "none", + }, + "vars": { + "MY_VAR": "my-var-value", + "MY_VAR_A": "my-var-a", + "MY_JSON_VAR": { + "test": true, + }, + }, + "images": { + "binding": "IMAGES", + }, + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + }, + ], + "r2_buckets": [ + { + "binding": "MY_BUCKET", + "bucket_name": "my-bucket", + }, + ], + "hyperdrive": [ + { + "binding": "MY_HYPERDRIVE", + "id": "000000000000000000000000000000000", + "localConnectionString": "postgres://user:pass@127.0.0.1:1234/db", + }, + ], + "d1_databases": [ + { + "binding": "MY_D1", + "database_name": "test-db", + "database_id": "000000000-0000-0000-0000-000000000000", + }, + ], + "env": { + "production": { + "vars": { + "MY_VAR": "my-PRODUCTION-var-value", + "MY_JSON_VAR": { + "test": true, + "production": true, + }, + }, + "kv_namespaces": [ + { + "binding": "MY_KV_PROD", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + }, + ], + }, + }, +} diff --git a/fixtures/get-platform-proxy/wrangler_external_do.jsonc b/fixtures/get-platform-proxy/wrangler_external_do.jsonc new file mode 100644 index 0000000..597fd3b --- /dev/null +++ b/fixtures/get-platform-proxy/wrangler_external_do.jsonc @@ -0,0 +1,24 @@ +{ + "name": "external-do", + "main": "src/index.ts", + // external durable object = binding has script_name. This indicates that + // the DO is exported in a separate Worker called `do-worker`. For the + // purposes of testing, we don't need to set that up because + // getPlatformProxy would not be involved in running that Worker. + + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + "script_name": "do-worker", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], +} diff --git a/fixtures/get-platform-proxy/wrangler_internal_do.jsonc b/fixtures/get-platform-proxy/wrangler_internal_do.jsonc new file mode 100644 index 0000000..f36328c --- /dev/null +++ b/fixtures/get-platform-proxy/wrangler_internal_do.jsonc @@ -0,0 +1,23 @@ +{ + "name": "internal-do", + "main": "src/index.ts", + // Internal durable object = the binding does not specify a script name. + // This implies the DO is exported alongside this worker in `index.ts`, + // which it isn't actually. However we don't care about this here because + // getPlatformProxy will discard all user code anyway. We are simply making + // sure the warning shows up. + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], +} diff --git a/fixtures/get-platform-proxy/wrangler_workflow.jsonc b/fixtures/get-platform-proxy/wrangler_workflow.jsonc new file mode 100644 index 0000000..d636acc --- /dev/null +++ b/fixtures/get-platform-proxy/wrangler_workflow.jsonc @@ -0,0 +1,22 @@ +{ + "name": "workflow-test", + "main": "src/index.ts", + // An internal workflow is indicated by the binding not specifying a script name. + // This implies the workflow is exported alongside this worker in `index.ts`, which it isn't actually. + // However we don't care about this here because `getPlatformProxy()` will discard all user code anyway. + // We are simply making sure the warning shows up. + "compatibility_date": "2023-11-21", + "workflows": [ + { + "binding": "MY_WORKFLOW_INTERNAL", + "name": "my-workflow-internal", + "class_name": "MyWorkflowInternal", + }, + { + "binding": "MY_WORKFLOW_EXTERNAL", + "name": "my-workflow-external", + "class_name": "MyWorkflowExternal", + "script_name": "OtherWorker", + }, + ], +} diff --git a/fixtures/import-npm/.gitignore b/fixtures/import-npm/.gitignore new file mode 100644 index 0000000..d8b83df --- /dev/null +++ b/fixtures/import-npm/.gitignore @@ -0,0 +1 @@ +package-lock.json diff --git a/fixtures/import-npm/README.md b/fixtures/import-npm/README.md new file mode 100644 index 0000000..2db0d93 --- /dev/null +++ b/fixtures/import-npm/README.md @@ -0,0 +1,3 @@ +# import-npm example + +This is an npm workspace within pnpm in order to test dependencies managed through npm can also be resolved by wrangler diff --git a/fixtures/import-npm/package.json b/fixtures/import-npm/package.json new file mode 100644 index 0000000..8755361 --- /dev/null +++ b/fixtures/import-npm/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fixture/import-npm", + "private": true, + "description": "", + "author": "", + "workspaces": [ + "packages/*" + ], + "scripts": { + "_clean_install": "rm -rf node_modules && npm install --no-package-lock", + "check:type": "npm run check:type --workspaces", + "test:ci": "npm run test:ci --workspaces", + "test:watch": "npm run test:watch --workspaces", + "type:tests": "npm run type:tests --workspaces" + } +} diff --git a/fixtures/import-npm/packages/import-example/package.json b/fixtures/import-npm/packages/import-example/package.json new file mode 100644 index 0000000..4892ef0 --- /dev/null +++ b/fixtures/import-npm/packages/import-example/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cloudflare/import-npm-fixture-import-example", + "private": true, + "description": "", + "author": "", + "main": "src/index.js", + "scripts": { + "check:type": "tsc", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "@fixture/import-wasm-static": "../../../../fixtures/import-wasm-static" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "../../../../packages/workers-tsconfig", + "undici": "^7.18.2", + "wrangler": "../../../../packages/wrangler" + } +} diff --git a/fixtures/import-npm/packages/import-example/src/index.js b/fixtures/import-npm/packages/import-example/src/index.js new file mode 100644 index 0000000..bf5533d --- /dev/null +++ b/fixtures/import-npm/packages/import-example/src/index.js @@ -0,0 +1,19 @@ +// this is from the `import-wasm-static` fixture defined above +// and setup inside package.json to mimic an npm package +import multiply from "@fixture/import-wasm-static/multiply.wasm"; +import otherMultiple from "@fixture/import-wasm-static/wasm/not-exported.wasm"; + +export default { + async fetch(request) { + // just instantiate and return something + // we're really just testing the imports at the top of this file + const multiplyModule = await WebAssembly.instantiate(multiply); + const otherModule = await WebAssembly.instantiate(otherMultiple); + + const results = [ + multiplyModule.exports.multiply(7, 3), + otherModule.exports.multiply(7, 3), + ]; + return new Response(results.join(", ")); + }, +}; diff --git a/fixtures/import-npm/packages/import-example/tests/index.test.ts b/fixtures/import-npm/packages/import-example/tests/index.test.ts new file mode 100644 index 0000000..2d4c185 --- /dev/null +++ b/fixtures/import-npm/packages/import-example/tests/index.test.ts @@ -0,0 +1,25 @@ +import { resolve } from "path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +describe("wrangler correctly imports wasm files with npm resolution", () => { + const server = createTestHarness({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); + + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + // if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly + it("responds", async ({ expect }) => { + const response = await server.fetch("/"); + const text = await response.text(); + expect(text).toBe("21, 21"); + }); +}); diff --git a/fixtures/import-npm/packages/import-example/tests/tsconfig.json b/fixtures/import-npm/packages/import-example/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/import-npm/packages/import-example/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/import-npm/packages/import-example/tsconfig.json b/fixtures/import-npm/packages/import-example/tsconfig.json new file mode 100644 index 0000000..65a5ca7 --- /dev/null +++ b/fixtures/import-npm/packages/import-example/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/import-npm/packages/import-example/vitest.config.mts b/fixtures/import-npm/packages/import-example/vitest.config.mts new file mode 100644 index 0000000..12e6a14 --- /dev/null +++ b/fixtures/import-npm/packages/import-example/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/import-npm/packages/import-example/wrangler.jsonc b/fixtures/import-npm/packages/import-example/wrangler.jsonc new file mode 100644 index 0000000..5b0f25d --- /dev/null +++ b/fixtures/import-npm/packages/import-example/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "import-example", + "compatibility_date": "2024-10-31", + "main": "src/index.js", +} diff --git a/fixtures/import-npm/turbo.json b/fixtures/import-npm/turbo.json new file mode 100644 index 0000000..ea3755d --- /dev/null +++ b/fixtures/import-npm/turbo.json @@ -0,0 +1,21 @@ +{ + "extends": ["//"], + "tasks": { + "_clean_install": { + "dependsOn": ["wrangler#build"], + "outputs": ["node_modules"] + }, + "check:type": { + "dependsOn": ["_clean_install"] + }, + "test:watch": { + "dependsOn": ["_clean_install"] + }, + "type:tests": { + "dependsOn": ["_clean_install"] + }, + "test:ci": { + "dependsOn": ["_clean_install"] + } + } +} diff --git a/fixtures/import-wasm-example/.gitignore b/fixtures/import-wasm-example/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/fixtures/import-wasm-example/.gitignore @@ -0,0 +1 @@ +dist diff --git a/fixtures/import-wasm-example/README.md b/fixtures/import-wasm-example/README.md new file mode 100644 index 0000000..565bd3c --- /dev/null +++ b/fixtures/import-wasm-example/README.md @@ -0,0 +1,4 @@ +# import-wasm-example + +`import-wasm-example` is a test fixture that imports a `wasm` file from `import-wasm-static`, testing npm module resolution with wrangler imports. +It also imports a file that isn't technically exported from `import-wasm-static`, testing more traditional node module resolution. diff --git a/fixtures/import-wasm-example/package.json b/fixtures/import-wasm-example/package.json new file mode 100644 index 0000000..1c3023c --- /dev/null +++ b/fixtures/import-wasm-example/package.json @@ -0,0 +1,23 @@ +{ + "name": "@fixture/import-wasm", + "private": true, + "description": "", + "author": "", + "main": "src/index.js", + "scripts": { + "check:type": "tsc", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "@fixture/import-wasm-static": "workspace:^" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/import-wasm-example/src/index.js b/fixtures/import-wasm-example/src/index.js new file mode 100644 index 0000000..bf5533d --- /dev/null +++ b/fixtures/import-wasm-example/src/index.js @@ -0,0 +1,19 @@ +// this is from the `import-wasm-static` fixture defined above +// and setup inside package.json to mimic an npm package +import multiply from "@fixture/import-wasm-static/multiply.wasm"; +import otherMultiple from "@fixture/import-wasm-static/wasm/not-exported.wasm"; + +export default { + async fetch(request) { + // just instantiate and return something + // we're really just testing the imports at the top of this file + const multiplyModule = await WebAssembly.instantiate(multiply); + const otherModule = await WebAssembly.instantiate(otherMultiple); + + const results = [ + multiplyModule.exports.multiply(7, 3), + otherModule.exports.multiply(7, 3), + ]; + return new Response(results.join(", ")); + }, +}; diff --git a/fixtures/import-wasm-example/tests/index.test.ts b/fixtures/import-wasm-example/tests/index.test.ts new file mode 100644 index 0000000..2d4c185 --- /dev/null +++ b/fixtures/import-wasm-example/tests/index.test.ts @@ -0,0 +1,25 @@ +import { resolve } from "path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +describe("wrangler correctly imports wasm files with npm resolution", () => { + const server = createTestHarness({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); + + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + // if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly + it("responds", async ({ expect }) => { + const response = await server.fetch("/"); + const text = await response.text(); + expect(text).toBe("21, 21"); + }); +}); diff --git a/fixtures/import-wasm-example/tests/tsconfig.json b/fixtures/import-wasm-example/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/import-wasm-example/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/import-wasm-example/tsconfig.json b/fixtures/import-wasm-example/tsconfig.json new file mode 100644 index 0000000..65a5ca7 --- /dev/null +++ b/fixtures/import-wasm-example/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/import-wasm-example/vitest.config.mts b/fixtures/import-wasm-example/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/import-wasm-example/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/import-wasm-example/wrangler.jsonc b/fixtures/import-wasm-example/wrangler.jsonc new file mode 100644 index 0000000..745c4cb --- /dev/null +++ b/fixtures/import-wasm-example/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "import-wasm-example", + "compatibility_date": "2023-10-02", + "main": "src/index.js", +} diff --git a/fixtures/import-wasm-static/README.md b/fixtures/import-wasm-static/README.md new file mode 100644 index 0000000..71f461e --- /dev/null +++ b/fixtures/import-wasm-static/README.md @@ -0,0 +1,5 @@ +# import-wasm-static + +`import-wasm-static` is a fixture that simply exports a `wasm` file via `package.json` exports to be used and imported in other fixtures, to test npm module resolution. + +It also provides a `not-exported.wasm` file that is not exported via `package.json` exports, to test node module resolution. diff --git a/fixtures/import-wasm-static/package.json b/fixtures/import-wasm-static/package.json new file mode 100644 index 0000000..92315ab --- /dev/null +++ b/fixtures/import-wasm-static/package.json @@ -0,0 +1,8 @@ +{ + "name": "@fixture/import-wasm-static", + "private": true, + "sideEffects": false, + "exports": { + "./multiply.wasm": "./wasm/multiply.wasm" + } +} diff --git a/fixtures/import-wasm-static/wasm/multiply.wasm b/fixtures/import-wasm-static/wasm/multiply.wasm new file mode 100644 index 0000000..0fbf927 Binary files /dev/null and b/fixtures/import-wasm-static/wasm/multiply.wasm differ diff --git a/fixtures/import-wasm-static/wasm/multiply.wat b/fixtures/import-wasm-static/wasm/multiply.wat new file mode 100644 index 0000000..064fbe7 --- /dev/null +++ b/fixtures/import-wasm-static/wasm/multiply.wat @@ -0,0 +1,7 @@ +(module + (func $multiply (param $p1 i32) (param $p2 i32) (result i32) + local.get $p1 + local.get $p2 + i32.mul) + (export "multiply" (func $multiply)) +) diff --git a/fixtures/import-wasm-static/wasm/not-exported.wasm b/fixtures/import-wasm-static/wasm/not-exported.wasm new file mode 100644 index 0000000..0fbf927 Binary files /dev/null and b/fixtures/import-wasm-static/wasm/not-exported.wasm differ diff --git a/fixtures/import-wasm-static/wasm/not-exported.wat b/fixtures/import-wasm-static/wasm/not-exported.wat new file mode 100644 index 0000000..064fbe7 --- /dev/null +++ b/fixtures/import-wasm-static/wasm/not-exported.wat @@ -0,0 +1,7 @@ +(module + (func $multiply (param $p1 i32) (param $p2 i32) (result i32) + local.get $p1 + local.get $p2 + i32.mul) + (export "multiply" (func $multiply)) +) diff --git a/fixtures/interactive-dev-tests/README.md b/fixtures/interactive-dev-tests/README.md new file mode 100644 index 0000000..a52158a --- /dev/null +++ b/fixtures/interactive-dev-tests/README.md @@ -0,0 +1,3 @@ +# `interactive-dev-tests` + +This package contains tests for interactive `wrangler dev` sessions running in a pseudo-TTY. These have slightly different behaviour to non-interactive sessions tested by other fixtures. diff --git a/fixtures/interactive-dev-tests/container-app/Dockerfile b/fixtures/interactive-dev-tests/container-app/Dockerfile new file mode 100644 index 0000000..eaeb93f --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app", "version": "1.0.0", "dependencies": {"ws": "^8.0.0"}}' > package.json +RUN npm install + +COPY ./container/simple-node-app.js app.js +EXPOSE 8080 diff --git a/fixtures/interactive-dev-tests/container-app/DockerfileWithLongSleep b/fixtures/interactive-dev-tests/container-app/DockerfileWithLongSleep new file mode 100644 index 0000000..c2fe7c6 --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/DockerfileWithLongSleep @@ -0,0 +1,6 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app + +RUN echo 'This (no-op) build takes forever...' +RUN sleep 50000 diff --git a/fixtures/interactive-dev-tests/container-app/container/simple-node-app.js b/fixtures/interactive-dev-tests/container-app/container/simple-node-app.js new file mode 100644 index 0000000..fd5e60e --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/container/simple-node-app.js @@ -0,0 +1,49 @@ +const { createServer } = require("http"); + +const webSocketEnabled = process.env.WS_ENABLED === "true"; + +// Create HTTP server +const server = createServer(function (req, res) { + if (req.url === "/ws") { + // WebSocket upgrade will be handled by the WebSocket server + return; + } + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("Hello World! Have an env var! " + process.env.MESSAGE); + res.end(); +}); + +// Check if WebSocket functionality is enabled +if (webSocketEnabled) { + const WebSocket = require("ws"); + + // Create WebSocket server + const wss = new WebSocket.Server({ + server: server, + path: "/ws", + }); + + wss.on("connection", function connection(ws) { + console.log("WebSocket connection established"); + + ws.on("message", function message(data) { + console.log("Received:", data.toString()); + // Echo the message back with prefix + ws.send("Echo: " + data.toString()); + }); + + ws.on("close", function close() { + console.log("WebSocket connection closed"); + }); + + ws.on("error", console.error); + }); +} + +server.listen(8080, function () { + console.log("Server listening on port 8080"); + if (webSocketEnabled) { + console.log("WebSocket support enabled"); + } +}); diff --git a/fixtures/interactive-dev-tests/container-app/src/index.ts b/fixtures/interactive-dev-tests/container-app/src/index.ts new file mode 100644 index 0000000..9b42281 --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/src/index.ts @@ -0,0 +1,67 @@ +import { DurableObject } from "cloudflare:workers"; + +export class FixtureTestContainer extends DurableObject<Env> { + container: globalThis.Container; + monitor?: Promise<unknown>; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.container = ctx.container; + } + + async fetch(req: Request) { + const path = new URL(req.url).pathname; + switch (path) { + case "/status": + return new Response(JSON.stringify(this.container.running)); + + case "/destroy": + if (!this.container.running) { + throw new Error("Container is not running."); + } + await this.container.destroy(); + return new Response(JSON.stringify(this.container.running)); + + case "/start": + this.container.start({ + entrypoint: ["node", "app.js"], + env: { MESSAGE: "I'm an env var!" }, + enableInternet: false, + }); + // this doesn't instantly start, so we will need to poll /fetch + return new Response("Container create request sent..."); + + case "/fetch": + const res = await this.container + .getTcpPort(8080) + // actual request doesn't matter + .fetch("http://foo/bar/baz", { method: "POST", body: "hello" }); + return new Response(await res.text()); + + case "/destroy-with-monitor": + const monitor = this.container.monitor(); + await this.container.destroy(); + await monitor; + return new Response("Container destroyed with monitor."); + + default: + return new Response("Hi from Container DO"); + } + } +} + +export default { + async fetch(request, env): Promise<Response> { + const url = new URL(request.url); + if (url.pathname === "/second") { + // This is a second Durable Object that can be used to test multiple DOs + const id = env.CONTAINER.idFromName("second-container"); + const stub = env.CONTAINER.get(id); + const query = url.searchParams.get("req"); + return stub.fetch("http://example.com/" + query); + } + const id = env.CONTAINER.idFromName("container"); + const stub = env.CONTAINER.get(id); + return stub.fetch(request); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/interactive-dev-tests/container-app/src/tsconfig.json b/fixtures/interactive-dev-tests/container-app/src/tsconfig.json new file mode 100644 index 0000000..e4a18bc --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/interactive-dev-tests/container-app/src/worker-configuration.d.ts b/fixtures/interactive-dev-tests/container-app/src/worker-configuration.d.ts new file mode 100644 index 0000000..fd1bb2f --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/src/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./container-app/src/worker-configuration.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime` (hash: 3e9f96a73efdcd8aacad1235e0761dab) +interface __BaseEnv_Env { + CONTAINER: DurableObjectNamespace<import("./index").FixtureTestContainer>; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "FixtureTestContainer"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/interactive-dev-tests/container-app/wrangler.jsonc b/fixtures/interactive-dev-tests/container-app/wrangler.jsonc new file mode 100644 index 0000000..3a2d79b --- /dev/null +++ b/fixtures/interactive-dev-tests/container-app/wrangler.jsonc @@ -0,0 +1,27 @@ +{ + "name": "container-app", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "image": "./Dockerfile", + "class_name": "FixtureTestContainer", + "name": "container", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "FixtureTestContainer", + "name": "CONTAINER", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainer"], + }, + ], +} diff --git a/fixtures/interactive-dev-tests/index.js b/fixtures/interactive-dev-tests/index.js new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/fixtures/interactive-dev-tests/index.js @@ -0,0 +1 @@ +export default {}; diff --git a/fixtures/interactive-dev-tests/multi-containers-app/DockerfileA b/fixtures/interactive-dev-tests/multi-containers-app/DockerfileA new file mode 100644 index 0000000..47b7031 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/DockerfileA @@ -0,0 +1,20 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app-a", "version": "1.0.0"}' > package.json +RUN npm install + +RUN sleep 1 + +RUN echo 'const { createServer } = require("http");\ +\ +const server = createServer(function (req, res) {\ + res.writeHead(200, { "Content-Type": "text/plain" });\ + res.write("Hello from Container A");\ + res.end();\ +});\ +\ +server.listen(8080);\ +' > app.js + +EXPOSE 8080 diff --git a/fixtures/interactive-dev-tests/multi-containers-app/DockerfileB b/fixtures/interactive-dev-tests/multi-containers-app/DockerfileB new file mode 100644 index 0000000..f6aa17b --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/DockerfileB @@ -0,0 +1,20 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app-b", "version": "1.0.0"}' > package.json +RUN npm install + +RUN sleep 1 + +RUN echo 'const { createServer } = require("http");\ +\ +const server = createServer(function (req, res) {\ + res.writeHead(200, { "Content-Type": "text/plain" });\ + res.write("Hello from Container B");\ + res.end();\ +});\ +\ +server.listen(8080);\ +' > app.js + +EXPOSE 8080 diff --git a/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts b/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts new file mode 100644 index 0000000..262872a --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts @@ -0,0 +1,49 @@ +import { DurableObject } from "cloudflare:workers"; + +class FixtureTestContainerBase extends DurableObject<Env> { + container: globalThis.Container; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.container = ctx.container; + } + + async fetch(req: Request) { + if (!this.container.running) { + this.container.start({ + entrypoint: ["node", "app.js"], + enableInternet: false, + }); + // On the first request we simply start the container and return, + // on the following requests the container can actually be accessed. + // Note that we do this this way because container.start is not awaitable + // meaning that we can't simply wait here for the container to be ready + return new Response("Container started"); + } + return this.container + .getTcpPort(8080) + .fetch("http://foo/bar/baz", { method: "POST", body: "hello" }); + } +} + +export class FixtureTestContainerA extends FixtureTestContainerBase {} +export class FixtureTestContainerB extends FixtureTestContainerBase {} + +export default { + async fetch(request, env): Promise<Response> { + const getContainerText = async ( + container: "CONTAINER_A" | "CONTAINER_B" + ) => { + const id = env[container].idFromName("container"); + const stub = env[container].get(id); + return await (await stub.fetch(request)).text(); + }; + const containerAText = await getContainerText("CONTAINER_A"); + const containerBText = await getContainerText("CONTAINER_B"); + return new Response( + `Response from A: "${containerAText}"` + + " " + + `Response from B: "${containerBText}"` + ); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/interactive-dev-tests/multi-containers-app/src/tsconfig.json b/fixtures/interactive-dev-tests/multi-containers-app/src/tsconfig.json new file mode 100644 index 0000000..e4a18bc --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/interactive-dev-tests/multi-containers-app/src/worker-configuration.d.ts b/fixtures/interactive-dev-tests/multi-containers-app/src/worker-configuration.d.ts new file mode 100644 index 0000000..dfc01f3 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/src/worker-configuration.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multi-containers-app/src/worker-configuration.d.ts -c ./multi-containers-app/wrangler.jsonc --no-include-runtime` (hash: bbb41fc3f9d6ad4abf178ab91cb66515) +interface __BaseEnv_Env { + CONTAINER_A: DurableObjectNamespace<import("./index").FixtureTestContainerA>; + CONTAINER_B: DurableObjectNamespace<import("./index").FixtureTestContainerB>; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "FixtureTestContainerA" | "FixtureTestContainerB"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/interactive-dev-tests/multi-containers-app/wrangler.jsonc b/fixtures/interactive-dev-tests/multi-containers-app/wrangler.jsonc new file mode 100644 index 0000000..e198197 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-containers-app/wrangler.jsonc @@ -0,0 +1,41 @@ +{ + "name": "multi-containers-app", + "main": "src/index.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "image": "./DockerfileA", + "class_name": "FixtureTestContainerA", + "name": "containerA", + "max_instances": 2, + }, + { + "image": "./DockerfileB", + "class_name": "FixtureTestContainerB", + "name": "containerB", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "FixtureTestContainerA", + "name": "CONTAINER_A", + }, + { + "class_name": "FixtureTestContainerB", + "name": "CONTAINER_B", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainerA"], + }, + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainerB"], + }, + ], +} diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json b/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json new file mode 100644 index 0000000..cee17af --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile new file mode 100644 index 0000000..80908ad --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json +RUN npm install + +RUN echo 'const { createServer } = require("http");\ +\ +const server = createServer(function (req, res) {\ + res.writeHead(200, { "Content-Type": "text/plain" });\ + res.write("Hello from Container A");\ + res.end();\ +});\ +\ +server.listen(8080);\ +' > app.js + +EXPOSE 8080 diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts new file mode 100644 index 0000000..cf8177b --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts @@ -0,0 +1,38 @@ +import { DurableObject } from "cloudflare:workers"; + +export class FixtureTestContainerA extends DurableObject<Env> { + container: globalThis.Container; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.container = ctx.container; + } + + async fetch(req: Request) { + if (!this.container.running) { + this.container.start({ + entrypoint: ["node", "app.js"], + enableInternet: false, + }); + // On the first request we simply start the container and return, + // on the following requests the container can actually be accessed. + // Note that we do this this way because container.start is not awaitable + // meaning that we can't simply wait here for the container to be ready + return new Response("Container started"); + } + return this.container + .getTcpPort(8080) + .fetch("http://foo/bar/baz", { method: "POST", body: "hello" }); + } +} + +export default { + async fetch(request, env): Promise<Response> { + const id = env.CONTAINER_A.idFromName("container"); + const stub = env.CONTAINER_A.get(id); + return Response.json({ + containerAText: await (await stub.fetch(request)).text(), + containerBText: await (await env.WORKER_B.fetch(request)).text(), + }); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts new file mode 100644 index 0000000..63993be --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multi-workers-containers-app/workerA/worker-configuration.d.ts -c ./multi-workers-containers-app/workerA/wrangler.jsonc --no-include-runtime` (hash: e414ffcd6be2e270f12de677a3fccaaf) +interface __BaseEnv_Env { + CONTAINER_A: DurableObjectNamespace<import("./index").FixtureTestContainerA>; + WORKER_B: Fetcher /* worker-b */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "FixtureTestContainerA"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc new file mode 100644 index 0000000..0ea4daa --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "name": "worker-a", + "main": "index.ts", + "compatibility_date": "2025-04-03", + "services": [{ "binding": "WORKER_B", "service": "worker-b" }], + "containers": [ + { + "image": "./Dockerfile", + "class_name": "FixtureTestContainerA", + "name": "container", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "FixtureTestContainerA", + "name": "CONTAINER_A", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["FixtureTestContainerA"], + }, + ], +} diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile new file mode 100644 index 0000000..3e06448 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json +RUN npm install + +RUN echo 'const { createServer } = require("http");\ +\ +const server = createServer(function (req, res) {\ + res.writeHead(200, { "Content-Type": "text/plain" });\ + res.write("Hello from Container B");\ + res.end();\ +});\ +\ +server.listen(8080);\ +' > app.js + +EXPOSE 8080 diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts new file mode 100644 index 0000000..9e300cd --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts @@ -0,0 +1,35 @@ +import { DurableObject } from "cloudflare:workers"; + +export class FixtureTestContainerB extends DurableObject<Env> { + container: globalThis.Container; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.container = ctx.container; + } + + async fetch(req: Request) { + if (!this.container.running) { + this.container.start({ + entrypoint: ["node", "app.js"], + enableInternet: false, + }); + // On the first request we simply start the container and return, + // on the following requests the container can actually be accessed. + // Note that we do this this way because container.start is not awaitable + // meaning that we can't simply wait here for the container to be ready + return new Response("Container started"); + } + return this.container + .getTcpPort(8080) + .fetch("http://foo/bar/baz", { method: "POST", body: "hello" }); + } +} + +export default { + async fetch(request, env): Promise<Response> { + const id = env.CONTAINER_B.idFromName("container"); + const stub = env.CONTAINER_B.get(id); + return stub.fetch(request); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts new file mode 100644 index 0000000..c97ba41 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multi-workers-containers-app/workerB/worker-configuration.d.ts -c ./multi-workers-containers-app/workerB/wrangler.jsonc --no-include-runtime` (hash: ffbf775a9ee9c3d83577a68a6b563542) +interface __BaseEnv_Env { + CONTAINER_B: DurableObjectNamespace<import("./index").FixtureTestContainerB>; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "FixtureTestContainerB"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc new file mode 100644 index 0000000..8af9d53 --- /dev/null +++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc @@ -0,0 +1,27 @@ +{ + "name": "worker-b", + "main": "index.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "image": "./Dockerfile", + "class_name": "FixtureTestContainerB", + "name": "container", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "FixtureTestContainerB", + "name": "CONTAINER_B", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["FixtureTestContainerB"], + }, + ], +} diff --git a/fixtures/interactive-dev-tests/package.json b/fixtures/interactive-dev-tests/package.json new file mode 100644 index 0000000..104aed1 --- /dev/null +++ b/fixtures/interactive-dev-tests/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/interactive-dev", + "private": true, + "scripts": { + "cf-typegen": "pnpm run \"/^typegen:.*/\"", + "test:ci": "vitest run", + "test:watch": "vitest", + "typegen:container-app": "wrangler types ./container-app/src/worker-configuration.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime", + "typegen:multi-containers-app": "wrangler types ./multi-containers-app/src/worker-configuration.d.ts -c ./multi-containers-app/wrangler.jsonc --no-include-runtime", + "typegen:multi-workers-containers-app": "wrangler types ./multi-workers-containers-app/workerA/worker-configuration.d.ts -c ./multi-workers-containers-app/workerA/wrangler.jsonc --no-include-runtime && wrangler types ./multi-workers-containers-app/workerB/worker-configuration.d.ts -c ./multi-workers-containers-app/workerB/wrangler.jsonc --no-include-runtime" + }, + "devDependencies": { + "@fixture/shared": "workspace:*", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + }, + "optionalDependencies": { + "@cdktf/node-pty-prebuilt-multiarch": "0.10.2" + } +} diff --git a/fixtures/interactive-dev-tests/public/index.html b/fixtures/interactive-dev-tests/public/index.html new file mode 100644 index 0000000..68fea10 --- /dev/null +++ b/fixtures/interactive-dev-tests/public/index.html @@ -0,0 +1 @@ +<p>body</p> diff --git a/fixtures/interactive-dev-tests/src/index.mjs b/fixtures/interactive-dev-tests/src/index.mjs new file mode 100644 index 0000000..0ef27d4 --- /dev/null +++ b/fixtures/interactive-dev-tests/src/index.mjs @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response("body"); + }, +}; diff --git a/fixtures/interactive-dev-tests/src/startup-error.ts b/fixtures/interactive-dev-tests/src/startup-error.ts new file mode 100644 index 0000000..5837167 --- /dev/null +++ b/fixtures/interactive-dev-tests/src/startup-error.ts @@ -0,0 +1,5 @@ +export default { + async fetch(): Promise<Response { + return new Response("body"); + }, +}; diff --git a/fixtures/interactive-dev-tests/src/worker-a.mjs b/fixtures/interactive-dev-tests/src/worker-a.mjs new file mode 100644 index 0000000..6b48cb7 --- /dev/null +++ b/fixtures/interactive-dev-tests/src/worker-a.mjs @@ -0,0 +1,7 @@ +export default { + async fetch(req, env) { + return new Response( + "hello from a & " + (await env.WORKER.fetch(req).then((r) => r.text())) + ); + }, +}; diff --git a/fixtures/interactive-dev-tests/src/worker-b.mjs b/fixtures/interactive-dev-tests/src/worker-b.mjs new file mode 100644 index 0000000..5d190a0 --- /dev/null +++ b/fixtures/interactive-dev-tests/src/worker-b.mjs @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response("hello from b"); + }, +}; diff --git a/fixtures/interactive-dev-tests/tests/index.test.ts b/fixtures/interactive-dev-tests/tests/index.test.ts new file mode 100644 index 0000000..a066e3a --- /dev/null +++ b/fixtures/interactive-dev-tests/tests/index.test.ts @@ -0,0 +1,943 @@ +import childProcess, { execSync } from "node:child_process"; +import fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import rl from "node:readline"; +import stream from "node:stream"; +import { stripVTControlCharacters } from "node:util"; +import { removeDir } from "@fixture/shared/src/fs-helpers"; +import { fetch } from "undici"; +/* eslint-disable workers-sdk/no-vitest-import-expect -- complex test with .each patterns */ +import { + afterAll, + afterEach, + assert, + describe as baseDescribe, + beforeAll, + expect, + it, + vi, +} from "vitest"; +/* eslint-enable workers-sdk/no-vitest-import-expect */ +import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived"; +import type pty from "@cdktf/node-pty-prebuilt-multiarch"; + +// These tests are failing with `Error: read EPIPE` on Windows in CI. There's still value running them on macOS and Linux. +if (process.platform === "win32") { + baseDescribe("interactive dev session tests", () => { + it.skip("skipped on Windows", () => {}); + }); +} else { + // Windows doesn't have a built-in way to get the CWD of a process by its ID. + // This functionality is provided by the Windows Driver Kit which is installed + // on GitHub actions Windows runners. + const tlistPath = + "C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x86\\tlist.exe"; + let windowsProcessCwdSupported = true; + if (!fs.existsSync(tlistPath)) { + windowsProcessCwdSupported = false; + const message = [ + "=".repeat(80), + "Unable to find Windows Driver Kit, skipping zombie process tests... :(", + "=".repeat(80), + ].join("\n"); + console.error(message); + } + + const pkgRoot = path.resolve(__dirname, ".."); + const ptyOptions: pty.IPtyForkOptions = { + name: "xterm-color", + cols: 80, + rows: 30, + cwd: pkgRoot, + env: process.env as Record<string, string>, + }; + + // Check `node-pty` installed and working correctly, skipping tests if not + let nodePtySupported = true; + try { + const pty = await import("@cdktf/node-pty-prebuilt-multiarch"); + const ptyProcess = pty.spawn( + process.execPath, + ["-p", "'ran node'"], + ptyOptions + ); + let output = ""; + ptyProcess.onData((data) => (output += data)); + const code = await new Promise<number>((resolve) => + ptyProcess.onExit(({ exitCode }) => resolve(exitCode)) + ); + assert.strictEqual(code, 0); + assert(output.includes("ran node")); + } catch (e) { + nodePtySupported = false; + const message = [ + "=".repeat(80), + "`node-pty` unsupported, skipping interactive dev session tests... :(", + "", + "Ensure its dependencies (https://github.com/microsoft/node-pty#dependencies)", + "are installed, then re-run `pnpm install` in the repository root.", + "", + "On Windows, make sure you have `Desktop development with C++`, `Windows SDK`,", + "`MSVC VS C++ build tools`, and `MSVC VS C++ Spectre-mitigated libs` Visual", + "Studio components installed.", + "", + e instanceof Error ? e.stack : String(e), + "=".repeat(80), + ].join("\n"); + console.error(message); + } + const describe = baseDescribe.runIf(nodePtySupported); + + interface PtyProcess { + pty: pty.IPty; + stdout: string; + exitCode: number | null; + exitPromise: Promise<number>; + url: string; + } + const processes: PtyProcess[] = []; + afterEach(() => { + for (const p of processes.splice(0)) { + // If the process didn't exit cleanly, log its output for debugging + if (p.exitCode !== 0) console.log(p.stdout); + // If the process hasn't exited yet, kill it + if (p.exitCode === null) { + // `node-pty` throws if signal passed on Windows + if (process.platform === "win32") p.pty.kill(); + else p.pty.kill("SIGKILL"); + } + } + }); + + const readyRegexp = /Ready on (http:\/\/[a-z0-9.]+:[0-9]+)/; + async function startWranglerDev( + args: string[], + skipWaitingForReady = false, + env?: Record<string, string> + ) { + const stdoutStream = new stream.PassThrough(); + const stdoutInterface = rl.createInterface(stdoutStream); + + let exitResolve: ((code: number) => void) | undefined; + const exitPromise = new Promise<number>( + (resolve) => (exitResolve = resolve) + ); + + const ptyOptionsWithEnv = { + ...ptyOptions, + env: env ?? (process.env as Record<string, string>), + } satisfies pty.IPtyForkOptions; + + const pty = await import("@cdktf/node-pty-prebuilt-multiarch"); + const ptyProcess = pty.spawn( + process.execPath, + [ + wranglerEntryPath, + ...args, + "--ip=127.0.0.1", + "--port=0", + "--inspector-port=0", + ], + ptyOptionsWithEnv + ); + const result: PtyProcess = { + pty: ptyProcess, + stdout: "", + exitCode: null, + exitPromise, + url: "", + }; + processes.push(result); + ptyProcess.onData((data) => { + result.stdout += data; + stdoutStream.write(data); + }); + ptyProcess.onExit(({ exitCode }) => { + result.exitCode = exitCode; + exitResolve?.(exitCode); + stdoutStream.end(); + }); + + if (!skipWaitingForReady) { + let readyMatch: RegExpMatchArray | null = null; + for await (const line of stdoutInterface) { + if ( + (readyMatch = readyRegexp.exec(stripVTControlCharacters(line))) !== + null + ) + break; + } + assert(readyMatch !== null, "Expected ready message"); + result.url = readyMatch[1]; + } + return result; + } + + interface Process { + pid: string; + cmd: string; + } + function getProcesses(): Process[] { + if (process.platform === "win32") { + return childProcess + .execSync("tasklist /fo csv", { encoding: "utf8" }) + .trim() + .split("\r\n") + .slice(1) + .map((line) => { + const [cmd, pid] = line.replaceAll('"', "").split(","); + return { pid, cmd }; + }); + } else { + return childProcess + .execSync("ps -e | awk '{print $1,$4}'", { encoding: "utf8" }) + .trim() + .split("\n") + .map((line) => { + const [pid, cmd] = line.split(" "); + return { pid, cmd }; + }); + } + } + function getProcessCwd(pid: string | number) { + if (process.platform === "win32") { + if (windowsProcessCwdSupported) { + return ( + childProcess + .spawnSync(tlistPath, [String(pid)], { encoding: "utf8" }) + .stdout.match(/^\s*CWD:\s*(.+)\\$/m)?.[1] ?? "" + ); + } else { + return ""; + } + } else { + return childProcess + .execSync(`lsof -p ${pid} | awk '$4=="cwd" {print $9}'`, { + encoding: "utf8", + }) + .trim(); + } + } + function getStartedWorkerdProcesses(): Process[] { + return getProcesses().filter( + ({ cmd, pid }) => + cmd.includes("workerd") && getProcessCwd(pid) === pkgRoot + ); + } + + const devScripts = [ + { args: ["dev"], expectedBody: "body" }, + { args: ["pages", "dev", "public"], expectedBody: "<p>body</p>" }, + ]; + const exitKeys = [ + { name: "CTRL-C", key: "\x03" }, + { name: "x", key: "x" }, + ]; + + describe.each(devScripts)("wrangler $args", ({ args, expectedBody }) => { + it.each(exitKeys)("cleanly exits with $name", async ({ key }) => { + const beginProcesses = getStartedWorkerdProcesses(); + + const wrangler = await startWranglerDev(args); + const duringProcesses = getStartedWorkerdProcesses(); + + // Check dev server working correctly + const res = await fetch(wrangler.url); + expect((await res.text()).trim()).toBe(expectedBody); + + // Check key cleanly exits dev server + wrangler.pty.write(key); + expect(await wrangler.exitPromise).toBe(0); + const endProcesses = getStartedWorkerdProcesses(); + + // Check no hanging workerd processes + if (process.platform !== "win32" || windowsProcessCwdSupported) { + expect(beginProcesses.length).toBe(endProcesses.length); + expect(duringProcesses.length).toBeGreaterThan(beginProcesses.length); + } + }); + describe("--show-interactive-dev-session", () => { + it("should show hotkeys when interactive", async () => { + const wrangler = await startWranglerDev(args); + wrangler.pty.kill(); + expect(wrangler.stdout).toContain("open a browser"); + expect(wrangler.stdout).toContain("open devtools"); + expect(wrangler.stdout).toContain("clear console"); + expect(wrangler.stdout).toContain("to exit"); + expect(wrangler.stdout).toContain("open local explorer"); + expect(wrangler.stdout).not.toContain("rebuild container"); + }); + it("should not show hotkeys with --show-interactive-dev-session=false", async () => { + const wrangler = await startWranglerDev([ + ...args, + "--show-interactive-dev-session=false", + ]); + wrangler.pty.kill(); + expect(wrangler.stdout).not.toContain("open a browser"); + expect(wrangler.stdout).not.toContain("open devtools"); + expect(wrangler.stdout).not.toContain("clear console"); + expect(wrangler.stdout).not.toContain("to exit"); + expect(wrangler.stdout).not.toContain("rebuild container"); + expect(wrangler.stdout).not.toContain("open local explorer"); + }); + }); + }); + + it.each(exitKeys)("multiworker cleanly exits with $name", async ({ key }) => { + const beginProcesses = getStartedWorkerdProcesses(); + + const wrangler = await startWranglerDev([ + "dev", + "-c", + "wrangler.a.jsonc", + "-c", + "wrangler.b.jsonc", + ]); + const duringProcesses = getStartedWorkerdProcesses(); + + // Check dev server working correctly + const res = await fetch(wrangler.url); + expect((await res.text()).trim()).toBe("hello from a & hello from b"); + + // Check key cleanly exits dev server + wrangler.pty.write(key); + expect(await wrangler.exitPromise).toBe(0); + const endProcesses = getStartedWorkerdProcesses(); + + // Check no hanging workerd processes + if (process.platform !== "win32" || windowsProcessCwdSupported) { + expect(beginProcesses.length).toBe(endProcesses.length); + expect(duringProcesses.length).toBeGreaterThan(beginProcesses.length); + } + }); + + const isCINonLinux = + process.platform !== "linux" && process.env.CI === "true"; + + function isDockerRunning() { + try { + execSync("docker ps", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } + } + + /** Indicates whether the test is being run locally (not in CI) AND docker is currently not running on the system */ + const isLocalWithoutDockerRunning = + process.env.CI !== "true" && !isDockerRunning(); + + if (isLocalWithoutDockerRunning) { + console.warn( + "The tests are running locally but there is no docker instance running on the system, skipping containers tests" + ); + } + + baseDescribe.skipIf( + isCINonLinux || + // If the tests are being run locally and docker is not running we just skip this test + isLocalWithoutDockerRunning + )("containers", () => { + // it seems like if we spam the container too often, it freezes up and crashes + const WAITFOR_OPTIONS = { timeout: 2000, interval: 500 }; + baseDescribe("container dev", { retry: 0, timeout: 90000 }, () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(tmpdir(), "wrangler-container-")); + fs.cpSync( + path.resolve(__dirname, "../", "container-app"), + path.join(tmpDir), + { + recursive: true, + } + ); + + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + + afterEach(async () => { + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + afterAll(() => { + removeDir(tmpDir, { fireAndForget: true }); + }); + + it("should print rebuild containers hotkey", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + wrangler.pty.kill(); + expect(wrangler.stdout).toContain("rebuild container"); + }); + + it("should rebuild a container when the hotkey is pressed", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + await fetch(wrangler.url + "/start"); + + // wait container to be ready + await vi.waitFor(async () => { + const status = await fetch(wrangler.url + "/status", { + signal: AbortSignal.timeout(500), + }); + expect(await status.json()).toBe(true); + }, WAITFOR_OPTIONS); + + await vi.waitFor(async () => { + const res = await fetch(wrangler.url + "/fetch", { + // Sometimes this fetch can hang if the container is not ready + // The default timeout is longer than the timeout on the `waitFor()` which results in the test failing. + // So abort this request sooner to allow it to retry. + signal: AbortSignal.timeout(500), + }); + + expect(await res.text()).toBe( + "Hello World! Have an env var! I'm an env var!" + ); + }, WAITFOR_OPTIONS); + const output = wrangler.stdout; + // Extract the Docker image name from the output + const imageNameMatch = output.match( + /cloudflare-dev\/fixturetestcontainer:[a-f0-9]+/ + ); + expect(imageNameMatch).not.toBe(null); + const imageName = imageNameMatch![0]; + expect( + JSON.parse( + execSync( + `docker image inspect ${imageName} --format "{{ json .RepoTags }}"`, + { encoding: "utf8" } + ) + ).length + ).toBeGreaterThanOrEqual(1); + + fs.writeFileSync( + path.join(tmpDir, "container", "simple-node-app.js"), + `const { createServer } = require("http"); + + const server = createServer(function (req, res) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("Blah! " + process.env.MESSAGE); + res.end(); + }); + + server.listen(8080, function () { + console.log("Server listening on port 8080"); + });`, + "utf-8" + ); + + // Clear the captured stdout so we can match on log output + // produced AFTER the rebuild hotkey is pressed. + wrangler.stdout = ""; + wrangler.pty.write("r"); + + // Wait for the rebuild and workerd reload to actually + // finish before asserting on `/status`. Without this, + // the old workerd (which still reports the container as + // running even after `docker rm`) can satisfy /status + // requests during the rebuild window, and requests can + // hang while miniflare is reloading — both cause flakes + // on slow CI. + await vi.waitFor( + () => { + expect(wrangler.stdout).toMatch(/Local server updated and ready/); + }, + { timeout: 30_000, interval: 500 } + ); + + await vi.waitFor(async () => { + const status = await fetch(wrangler.url + "/status", { + signal: AbortSignal.timeout(500), + }); + expect(await status.json()).toBe(false); + }, WAITFOR_OPTIONS); + + await fetch(wrangler.url + "/start"); + await vi.waitFor(async () => { + const status = await fetch(wrangler.url + "/status", { + signal: AbortSignal.timeout(500), + }); + expect(await status.json()).toBe(true); + }, WAITFOR_OPTIONS); + await vi.waitFor(async () => { + const res = await fetch(wrangler.url + "/fetch", { + signal: AbortSignal.timeout(500), + }); + expect(await res.text()).toBe("Blah! I'm an env var!"); + }, WAITFOR_OPTIONS); + + // Verify that the old image tag has been deleted after rebuild + expect(() => { + execSync(`docker image inspect ${imageName}`, { encoding: "utf8" }); + }).toThrow(); + + wrangler.pty.kill(); + }); + + it("should clean up any containers that were started", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + await fetch(wrangler.url + "/start"); + + // wait container to be ready + await vi.waitFor(async () => { + const ids = getContainerIds(); + expect(ids.length).toBe(1); + }, WAITFOR_OPTIONS); + + // ctrl + c + wrangler.pty.write("\x03"); + await new Promise<void>((resolve) => { + wrangler.pty.onExit(() => resolve()); + }); + + // `docker rm -f` of the running container after the SIGINT + // teardown can take several seconds on a busy CI runner, so + // allow more than the default 5s `vi.waitFor` timeout. + await vi.waitFor( + () => { + expect(getContainerIds()).toHaveLength(0); + }, + { timeout: 10_000, interval: 500 } + ); + }); + }); + + baseDescribe( + "container dev where image build takes a long time", + { retry: 0, timeout: 90000 }, + () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = fs.mkdtempSync( + path.join(tmpdir(), "wrangler-container-sleep-") + ); + fs.cpSync( + path.resolve(__dirname, "../", "container-app"), + path.join(tmpDir), + { + recursive: true, + } + ); + const tmpDockerFilePath = path.join(tmpDir, "Dockerfile"); + fs.rmSync(tmpDockerFilePath); + fs.renameSync( + path.join(tmpDir, "DockerfileWithLongSleep"), + tmpDockerFilePath + ); + + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + + afterEach(async () => { + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + + afterAll(() => { + removeDir(tmpDir, { fireAndForget: true }); + }); + + it("should allow quitting while the image is building", async () => { + const wrangler = await startWranglerDev( + ["dev", "-c", path.join(tmpDir, "wrangler.jsonc")], + true + ); + + // Use a generous timeout to accommodate slow CI runners — + // 10s was occasionally tight when the docker daemon was + // under contention from other parallel fixtures. + const waitForOptions = { timeout: 30_000, interval: 500 }; + + // wait for long sleep instruction to start + await vi.waitFor(async () => { + expect(wrangler.stdout).toContain("RUN sleep 50000"); + }, waitForOptions); + + wrangler.pty.write("q"); + + await vi.waitFor(async () => { + expect(wrangler.stdout).toMatch(/CANCELED \[.*?\] RUN sleep 50000/); + }, waitForOptions); + }); + + it("should rebuilding while the image is building", async () => { + const wrangler = await startWranglerDev( + ["dev", "-c", path.join(tmpDir, "wrangler.jsonc")], + true + ); + + // We assert on wrangler's own deterministic log line, which + // is emitted synchronously when wrangler picks up a new + // `containerBuildId` and starts the rebuild flow. We + // intentionally do NOT match "This (no-op) build takes + // forever..." or buildkit's `CANCELED` line — both come + // from docker buildkit's TTY output and are unreliable: + // the first is redrawn an unpredictable number of times + // per build, and the second is only emitted when buildkit + // has time to flush its cancellation status before the + // docker CLI is killed by the abort. Relying on either of + // those made earlier versions of this test flaky on slow + // CI (see + // https://github.com/cloudflare/workers-sdk/actions/runs/24924588002). + const PREPARING_RE = /⎔ Preparing container image\(s\)/; + const SLEEP_RE = /RUN sleep 50000/; + + const waitForOptions = { timeout: 30_000, interval: 500 }; + + // 1. Wait for the initial build to reach the long sleep + // instruction — this is the "while the image is building" + // state that the test name refers to. + await vi.waitFor(() => { + expect(wrangler.stdout).toMatch(SLEEP_RE); + }, waitForOptions); + + // 2. First `r`: should trigger another rebuild while the + // initial build is still in its long-sleep phase. Clear + // the captured stdout so the assertion below only sees + // output produced after the press. + wrangler.stdout = ""; + wrangler.pty.write("r"); + + await vi.waitFor(() => { + expect(wrangler.stdout).toMatch(PREPARING_RE); + }, waitForOptions); + + // 3. Wait for the second build to also reach the long sleep + // instruction so the next `r` press is genuinely "while + // the image is building". + await vi.waitFor(() => { + expect(wrangler.stdout).toMatch(SLEEP_RE); + }, waitForOptions); + + // 4. Second `r`: should again trigger another rebuild. + // Clear stdout again so the assertion only sees the new + // Preparing log. + wrangler.stdout = ""; + wrangler.pty.write("r"); + + await vi.waitFor(() => { + expect(wrangler.stdout).toMatch(PREPARING_RE); + }, waitForOptions); + + wrangler.pty.kill(); + }); + } + ); + + baseDescribe("multi-containers dev", { retry: 0, timeout: 50000 }, () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = fs.mkdtempSync( + path.join(tmpdir(), "wrangler-multi-containers-") + ); + fs.cpSync( + path.resolve(__dirname, "../", "multi-containers-app"), + path.join(tmpDir), + { + recursive: true, + } + ); + + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + + afterEach(async () => { + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + afterAll(() => { + removeDir(tmpDir, { fireAndForget: true }); + }); + + it("should print build logs for all the containers", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + await vi.waitFor( + () => { + expect(wrangler.stdout).toContain('"name": "simple-node-app-a"'); + expect(wrangler.stdout).toContain('"name": "simple-node-app-b"'); + }, + { timeout: 10_000 } + ); + wrangler.pty.kill(); + }); + + it("should rebuild all the containers when the hotkey is pressed", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + + await vi.waitFor( + async () => { + const text = await ( + await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) }) + ).text(); + expect(text).toBe( + 'Response from A: "Hello from Container A" Response from B: "Hello from Container B"' + ); + }, + { timeout: 30_000, interval: 1000 } + ); + + const tmpDockerfileAPath = path.join(tmpDir, "DockerfileA"); + const dockerFileAContent = fs.readFileSync(tmpDockerfileAPath, "utf8"); + fs.writeFileSync( + tmpDockerfileAPath, + dockerFileAContent.replace( + '"Hello from Container A"', + '"Hello World from Container A"' + ), + "utf-8" + ); + + const tmpDockerfileBPath = path.join(tmpDir, "DockerfileB"); + const dockerFileBContent = fs.readFileSync(tmpDockerfileBPath, "utf8"); + fs.writeFileSync( + tmpDockerfileBPath, + dockerFileBContent.replace( + '"Hello from Container B"', + '"Hello from the B Container"' + ), + "utf-8" + ); + + wrangler.pty.write("r"); + + await vi.waitFor( + async () => { + const text = await ( + await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) }) + ).text(); + expect(text).toBe( + 'Response from A: "Hello World from Container A" Response from B: "Hello from the B Container"' + ); + }, + { timeout: 30_000, interval: 1000 } + ); + + fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8"); + fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8"); + + wrangler.pty.kill(); + }); + + it("should clean up any containers that were started", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "wrangler.jsonc"), + ]); + // wait container to be ready + await vi.waitFor( + async () => { + const text = await ( + await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) }) + ).text(); + expect(text).toBe( + 'Response from A: "Hello from Container A" Response from B: "Hello from Container B"' + ); + }, + { timeout: 30_000, interval: 1000 } + ); + const ids = getContainerIds(); + expect(ids.length).toBe(2); + + // ctrl + c + wrangler.pty.write("\x03"); + await new Promise<void>((resolve) => { + wrangler.pty.onExit(() => resolve()); + }); + // `docker rm -f` of multiple running containers after the + // SIGINT teardown can take several seconds on a busy CI + // runner, so allow more than the default 5s `vi.waitFor` + // timeout. + await vi.waitFor( + () => { + const remainingIds = getContainerIds(); + expect(remainingIds).toHaveLength(0); + }, + { timeout: 10_000, interval: 500 } + ); + }); + }); + + baseDescribe( + "multi-workers containers dev", + { retry: 0, timeout: 50000 }, + () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = fs.mkdtempSync( + path.join(tmpdir(), "wrangler-multi-workers-containers-") + ); + fs.cpSync( + path.resolve(__dirname, "../", "multi-workers-containers-app"), + path.join(tmpDir), + { + recursive: true, + } + ); + + const ids = getContainerIds(); + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + + afterEach(async () => { + const ids = getContainerIds(); + + if (ids.length > 0) { + execSync("docker rm -f " + ids.join(" "), { + encoding: "utf8", + }); + } + }); + afterAll(() => { + removeDir(tmpDir, { fireAndForget: true }); + }); + + it("should be able to interact with both workers, rebuild the containers with the hotkey and all containers should be cleaned up at the end", async () => { + const wrangler = await startWranglerDev([ + "dev", + "-c", + path.join(tmpDir, "workerA/wrangler.jsonc"), + "-c", + path.join(tmpDir, "workerB/wrangler.jsonc"), + ]); + + await vi.waitFor( + async () => { + const json = await ( + await fetch(wrangler.url, { + signal: AbortSignal.timeout(5_000), + }) + ).json(); + expect(json).toEqual({ + containerAText: "Hello from Container A", + containerBText: "Hello from Container B", + }); + }, + { timeout: 10_000, interval: 1000 } + ); + + const tmpDockerfileAPath = path.join(tmpDir, "workerA/Dockerfile"); + const dockerFileAContent = fs.readFileSync( + tmpDockerfileAPath, + "utf8" + ); + fs.writeFileSync( + tmpDockerfileAPath, + dockerFileAContent.replace( + '"Hello from Container A"', + '"Hello World from Container A"' + ), + "utf-8" + ); + + const tmpDockerfileBPath = path.join(tmpDir, "workerB/Dockerfile"); + const dockerFileBContent = fs.readFileSync( + tmpDockerfileBPath, + "utf8" + ); + fs.writeFileSync( + tmpDockerfileBPath, + dockerFileBContent.replace( + '"Hello from Container B"', + '"Hello from the B Container"' + ), + "utf-8" + ); + + wrangler.pty.write("r"); + + await vi.waitFor( + async () => { + const json = await ( + await fetch(wrangler.url, { + signal: AbortSignal.timeout(5_000), + }) + ).json(); + expect(json).toEqual({ + containerAText: "Hello World from Container A", + containerBText: "Hello from the B Container", + }); + }, + { timeout: 30_000, interval: 1000 } + ); + + fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8"); + fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8"); + + wrangler.pty.kill("SIGINT"); + }); + } + ); + + /** gets any containers that were created by running this fixture */ + const getContainerIds = () => { + // note the -a to include stopped containers + const allContainers = execSync(`docker ps -a --format json`) + .toString() + .split("\n") + .filter((line) => line.trim()); + if (allContainers.length === 0) { + return []; + } + const jsonOutput = allContainers.map((line) => JSON.parse(line)); + + const matches = jsonOutput.map((container) => { + if (container.Image.includes("cloudflare-dev/fixturetestcontainer")) { + return container.ID; + } + }); + return matches.filter(Boolean); + }; + }); +} diff --git a/fixtures/interactive-dev-tests/tsconfig.json b/fixtures/interactive-dev-tests/tsconfig.json new file mode 100644 index 0000000..3241aae --- /dev/null +++ b/fixtures/interactive-dev-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "target": "esnext", + "module": "esnext", + "strict": true, + "lib": ["esnext"] + } +} diff --git a/fixtures/interactive-dev-tests/vitest.config.mts b/fixtures/interactive-dev-tests/vitest.config.mts new file mode 100644 index 0000000..d88b387 --- /dev/null +++ b/fixtures/interactive-dev-tests/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + // `node-pty` doesn't work inside worker threads + pool: "forks", + }, + }) +); diff --git a/fixtures/interactive-dev-tests/wrangler.a.jsonc b/fixtures/interactive-dev-tests/wrangler.a.jsonc new file mode 100644 index 0000000..33a9bfc --- /dev/null +++ b/fixtures/interactive-dev-tests/wrangler.a.jsonc @@ -0,0 +1,11 @@ +{ + "name": "a", + "main": "src/worker-a.mjs", + "compatibility_date": "2023-12-01", + "services": [ + { + "binding": "WORKER", + "service": "b", + }, + ], +} diff --git a/fixtures/interactive-dev-tests/wrangler.b.jsonc b/fixtures/interactive-dev-tests/wrangler.b.jsonc new file mode 100644 index 0000000..d2454c1 --- /dev/null +++ b/fixtures/interactive-dev-tests/wrangler.b.jsonc @@ -0,0 +1,5 @@ +{ + "name": "b", + "main": "src/worker-b.mjs", + "compatibility_date": "2023-12-01", +} diff --git a/fixtures/interactive-dev-tests/wrangler.jsonc b/fixtures/interactive-dev-tests/wrangler.jsonc new file mode 100644 index 0000000..b75d756 --- /dev/null +++ b/fixtures/interactive-dev-tests/wrangler.jsonc @@ -0,0 +1,4 @@ +{ + "main": "src/index.mjs", + "compatibility_date": "2023-12-01", +} diff --git a/fixtures/isomorphic-random-example/README.md b/fixtures/isomorphic-random-example/README.md new file mode 100644 index 0000000..0522830 --- /dev/null +++ b/fixtures/isomorphic-random-example/README.md @@ -0,0 +1,7 @@ +# Isomorphic Package Example + +This package implements an isomorphic library that generates cryptographically-strong pseudorandom numbers. +What this package does isn't really important here, the key part is the [`package.json`](./package.json)'s `exports` field. +Conditional exports provide a way to load a different file depending on where the module is being imported from. +By default, Wrangler will try to look for a [**`workerd`** key](https://runtime-keys.proposal.wintercg.org/#workerd). +This allows you as a library developer to implement different behaviour for different JavaScript runtimes. diff --git a/fixtures/isomorphic-random-example/package.json b/fixtures/isomorphic-random-example/package.json new file mode 100644 index 0000000..78a3d27 --- /dev/null +++ b/fixtures/isomorphic-random-example/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/isomorphic-random", + "private": true, + "description": "Isomorphic secure-random library, demonstrating `exports` use with Workers", + "exports": { + "other": "./src/other.js", + "node": "./src/node.js", + "workerd": "./src/workerd.mjs", + "default": "./src/default.js" + } +} diff --git a/fixtures/isomorphic-random-example/src/default.js b/fixtures/isomorphic-random-example/src/default.js new file mode 100644 index 0000000..71c6176 --- /dev/null +++ b/fixtures/isomorphic-random-example/src/default.js @@ -0,0 +1,4 @@ +// This entry point should only be used if no other build condition match. +export function randomBytes(length) { + return new Uint8Array([8, 9, 10, 11, 12, 13]); +} diff --git a/fixtures/isomorphic-random-example/src/node.js b/fixtures/isomorphic-random-example/src/node.js new file mode 100644 index 0000000..eb4c8af --- /dev/null +++ b/fixtures/isomorphic-random-example/src/node.js @@ -0,0 +1,5 @@ +const crypto = require("node:crypto"); + +module.exports.randomBytes = function (length) { + return new Uint8Array(crypto.randomBytes(length)); +}; diff --git a/fixtures/isomorphic-random-example/src/other.js b/fixtures/isomorphic-random-example/src/other.js new file mode 100644 index 0000000..860543a --- /dev/null +++ b/fixtures/isomorphic-random-example/src/other.js @@ -0,0 +1,4 @@ +// This entry point should only be used if the build condition contains `other`. +export function randomBytes(length) { + return new Uint8Array([1, 2, 3, 4, 5, 6]); +} diff --git a/fixtures/isomorphic-random-example/src/workerd.mjs b/fixtures/isomorphic-random-example/src/workerd.mjs new file mode 100644 index 0000000..36cb99d --- /dev/null +++ b/fixtures/isomorphic-random-example/src/workerd.mjs @@ -0,0 +1,3 @@ +export function randomBytes(length) { + return crypto.getRandomValues(new Uint8Array(length)); +} diff --git a/fixtures/legacy-site-app/package.json b/fixtures/legacy-site-app/package.json new file mode 100644 index 0000000..ae9c8d7 --- /dev/null +++ b/fixtures/legacy-site-app/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/sites-legacy", + "private": true, + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "main": "index.js" +} diff --git a/fixtures/legacy-site-app/public/404.html b/fixtures/legacy-site-app/public/404.html new file mode 100644 index 0000000..5ac9921 --- /dev/null +++ b/fixtures/legacy-site-app/public/404.html @@ -0,0 +1,50 @@ +<!doctype html> +<html> + <head> + <link rel="icon" type="image/x-icon" href="favicon.ico" /> + <link + href="https://fonts.googleapis.com/css?family=Pacifico&display=swap" + rel="stylesheet" + /> + <link + href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" + rel="stylesheet" + /> + <style> + h1 { + font-family: Pacifico, sans-serif; + font-size: 4em; + color: #3eb5f1; + margin: 0; + } + + h2 { + font-weight: 300; + font-family: sans-serif; + } + + .centered { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + } + + #ferris { + width: 75%; + } + </style> + </head> + <body> + <div class="centered"> + <h1>404 Not Found</h1> + <h2>Oh dang! We couldn't find that page.</h2> + <img + id="ferris" + alt="a sad crab is unable to unable to lasso a paper airplane. 404 not found." + src="./img/404-wrangler-ferris.gif" + /> + </div> + </body> +</html> diff --git a/fixtures/legacy-site-app/public/favicon.ico b/fixtures/legacy-site-app/public/favicon.ico new file mode 100644 index 0000000..cc6c23b Binary files /dev/null and b/fixtures/legacy-site-app/public/favicon.ico differ diff --git a/fixtures/legacy-site-app/public/img/200-wrangler-ferris.gif b/fixtures/legacy-site-app/public/img/200-wrangler-ferris.gif new file mode 100644 index 0000000..8853751 Binary files /dev/null and b/fixtures/legacy-site-app/public/img/200-wrangler-ferris.gif differ diff --git a/fixtures/legacy-site-app/public/img/404-wrangler-ferris.gif b/fixtures/legacy-site-app/public/img/404-wrangler-ferris.gif new file mode 100644 index 0000000..0ac1479 Binary files /dev/null and b/fixtures/legacy-site-app/public/img/404-wrangler-ferris.gif differ diff --git a/fixtures/legacy-site-app/public/index.html b/fixtures/legacy-site-app/public/index.html new file mode 100644 index 0000000..102985f --- /dev/null +++ b/fixtures/legacy-site-app/public/index.html @@ -0,0 +1,50 @@ +<!doctype html> +<html> + <head> + <link rel="icon" type="image/x-icon" href="favicon.ico" /> + <link + href="https://fonts.googleapis.com/css?family=Pacifico&display=swap" + rel="stylesheet" + /> + <link + href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" + rel="stylesheet" + /> + <style> + h1 { + font-family: Pacifico, sans-serif; + font-size: 4em; + color: #3eb5f1; + margin: 0; + } + + h2 { + font-weight: 300; + font-family: sans-serif; + } + + .centered { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + } + + #ferris { + width: 75%; + } + </style> + </head> + <body> + <div class="centered"> + <h1>200 Success</h1> + <h2>Hello World! Welcome to your Workers Site.</h2> + <img + id="ferris" + alt="a happy crab is wearing a cowboy hat and holding a lasso. 200 success." + src="./img/200-wrangler-ferris.gif" + /> + </div> + </body> +</html> diff --git a/fixtures/legacy-site-app/workers-site/.gitignore b/fixtures/legacy-site-app/workers-site/.gitignore new file mode 100644 index 0000000..7915249 --- /dev/null +++ b/fixtures/legacy-site-app/workers-site/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +worker diff --git a/fixtures/legacy-site-app/workers-site/index.js b/fixtures/legacy-site-app/workers-site/index.js new file mode 100644 index 0000000..48e8be8 --- /dev/null +++ b/fixtures/legacy-site-app/workers-site/index.js @@ -0,0 +1,87 @@ +import { + getAssetFromKV, + mapRequestToAsset, +} from "@cloudflare/kv-asset-handler"; + +/** + * The DEBUG flag will do two things that help during development: + * 1. we will skip caching on the edge, which makes it easier to + * debug. + * 2. we will return an error message on exception in your Response rather + * than the default 404.html page. + */ +const DEBUG = false; + +addEventListener("fetch", (event) => { + event.respondWith(handleEvent(event)); +}); + +async function handleEvent(event) { + let options = {}; + + /** + * You can add custom logic to how we fetch your assets + * by configuring the function `mapRequestToAsset` + */ + // options.mapRequestToAsset = handlePrefix(/^\/docs/) + + try { + if (DEBUG) { + // customize caching + options.cacheControl = { + bypassCache: true, + }; + } + + const page = await getAssetFromKV(event, options); + + // allow headers to be altered + const response = new Response(page.body, page); + + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "unsafe-url"); + response.headers.set("Feature-Policy", "none"); + + return response; + } catch (e) { + // if an error is thrown try to serve the asset at 404.html + if (!DEBUG) { + try { + let notFoundResponse = await getAssetFromKV(event, { + mapRequestToAsset: (req) => + new Request(`${new URL(req.url).origin}/404.html`, req), + }); + + return new Response(notFoundResponse.body, { + ...notFoundResponse, + status: 404, + }); + } catch (e) {} + } + + return new Response(e.message || e.toString(), { status: 500 }); + } +} + +/** + * Here's one example of how to modify a request to + * remove a specific prefix, in this case `/docs` from + * the url. This can be useful if you are deploying to a + * route on a zone, or if you only want your static content + * to exist at a specific path. + */ +function handlePrefix(prefix) { + return (request) => { + // compute the default (e.g. / -> index.html) + let defaultAssetKey = mapRequestToAsset(request); + let url = new URL(defaultAssetKey.url); + + // strip the prefix from the path for lookup + url.pathname = url.pathname.replace(prefix, "/"); + + // inherit all other props from the default request + return new Request(url.toString(), defaultAssetKey); + }; +} diff --git a/fixtures/legacy-site-app/workers-site/package.json b/fixtures/legacy-site-app/workers-site/package.json new file mode 100644 index 0000000..cf4244c --- /dev/null +++ b/fixtures/legacy-site-app/workers-site/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "description": "A template for kick starting a Cloudflare Workers project", + "license": "MIT", + "main": "index.js", + "dependencies": { + "@cloudflare/kv-asset-handler": "workspace:*" + } +} diff --git a/fixtures/legacy-site-app/wrangler.jsonc b/fixtures/legacy-site-app/wrangler.jsonc new file mode 100644 index 0000000..d29209f --- /dev/null +++ b/fixtures/legacy-site-app/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "account_id": "", + "name": "legacy-site-app", + "type": "webpack", + "workers_dev": true, + "site": { + "bucket": "./public", + }, + "compatibility_date": "2022-04-24", +} diff --git a/fixtures/miniflare-node-test/package.json b/fixtures/miniflare-node-test/package.json new file mode 100644 index 0000000..93e7de9 --- /dev/null +++ b/fixtures/miniflare-node-test/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/miniflare-node", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "scripts": { + "test:ci": "node --test" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/is-even": "^1.0.2", + "is-even": "^1.0.0", + "miniflare": "workspace:*", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/miniflare-node-test/src/index-build.test.js b/fixtures/miniflare-node-test/src/index-build.test.js new file mode 100644 index 0000000..a9985b5 --- /dev/null +++ b/fixtures/miniflare-node-test/src/index-build.test.js @@ -0,0 +1,41 @@ +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import test, { after, before, describe } from "node:test"; +import { Miniflare } from "miniflare"; + +before(() => { + spawnSync("npx wrangler build -c wrangler-build.json", { + shell: true, + stdio: "pipe", + }); +}); + +describe("worker build", () => { + /** + * @type {Miniflare} + */ + let worker; + + before(async () => { + worker = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "dist/index.js", + }, + ], + }); + await worker.ready; + }); + + test("even", async () => { + assert.strictEqual( + await (await worker.dispatchFetch("http://example.com?number=2")).text(), + "even" + ); + }); + + after(async () => { + await worker.dispose(); + }); +}); diff --git a/fixtures/miniflare-node-test/src/index-with-bindings.test.js b/fixtures/miniflare-node-test/src/index-with-bindings.test.js new file mode 100644 index 0000000..6287e9a --- /dev/null +++ b/fixtures/miniflare-node-test/src/index-with-bindings.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert"; +import test, { after, before, describe } from "node:test"; +import { Miniflare } from "miniflare"; + +describe("worker", () => { + /** + * @type {Miniflare} + */ + let worker; + + before(async () => { + worker = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "src/index.js", + }, + ], + bindings: { + FOO: "Hello Bindings", + }, + kvNamespaces: ["KV"], + }); + await worker.ready; + }); + + test("hello world", async () => { + assert.strictEqual( + await (await worker.dispatchFetch("http://example.com")).text(), + "Hello World" + ); + }); + + test("text binding", async () => { + const bindings = await worker.getBindings(); + assert.strictEqual(bindings.FOO, "Hello Bindings"); + }); + + test("kv binding", async () => { + /** + * @type {{KV: import("@cloudflare/workers-types/experimental").KVNamespace, FOO: string}} + */ + const bindings = await worker.getBindings(); + await bindings.KV.put("key", "value"); + assert.strictEqual(await bindings.KV.get("key"), "value"); + }); + + after(async () => { + await worker.dispose(); + }); +}); diff --git a/fixtures/miniflare-node-test/src/index-with-imports.js b/fixtures/miniflare-node-test/src/index-with-imports.js new file mode 100644 index 0000000..1c2052b --- /dev/null +++ b/fixtures/miniflare-node-test/src/index-with-imports.js @@ -0,0 +1,12 @@ +import { sayHello } from "./say-hello.js"; + +export default { + /** + * + * @param {Request} request + * @returns + */ + async fetch(request) { + return new Response(sayHello(request.url)); + }, +}; diff --git a/fixtures/miniflare-node-test/src/index-with-imports.test.js b/fixtures/miniflare-node-test/src/index-with-imports.test.js new file mode 100644 index 0000000..1a070ad --- /dev/null +++ b/fixtures/miniflare-node-test/src/index-with-imports.test.js @@ -0,0 +1,34 @@ +import assert from "node:assert"; +import test, { after, before, describe } from "node:test"; +import { Miniflare } from "miniflare"; + +describe("worker", () => { + /** + * @type {Miniflare} + */ + let worker; + + before(async () => { + worker = new Miniflare({ + scriptPath: "src/index-with-imports.js", + modules: true, + modulesRules: [{ type: "ESModule", include: ["**/*.js"] }], + }); + try { + await worker.ready; + } catch (e) { + console.error(e); + } + }); + + test("hello world", async () => { + assert.strictEqual( + await (await worker.dispatchFetch("http://example.com/path")).text(), + "Hello from http://example.com/path" + ); + }); + + after(async () => { + await worker.dispose(); + }); +}); diff --git a/fixtures/miniflare-node-test/src/index.js b/fixtures/miniflare-node-test/src/index.js new file mode 100644 index 0000000..2bd0131 --- /dev/null +++ b/fixtures/miniflare-node-test/src/index.js @@ -0,0 +1,10 @@ +export default { + /** + * + * @param {Request} request + * @returns + */ + async fetch(request) { + return new Response(`Hello World`); + }, +}; diff --git a/fixtures/miniflare-node-test/src/index.test.js b/fixtures/miniflare-node-test/src/index.test.js new file mode 100644 index 0000000..874f120 --- /dev/null +++ b/fixtures/miniflare-node-test/src/index.test.js @@ -0,0 +1,33 @@ +import assert from "node:assert"; +import test, { after, before, describe } from "node:test"; +import { Miniflare } from "miniflare"; + +describe("worker", () => { + /** + * @type {Miniflare} + */ + let worker; + + before(async () => { + worker = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "src/index.js", + }, + ], + }); + await worker.ready; + }); + + test("hello world", async () => { + assert.strictEqual( + await (await worker.dispatchFetch("http://example.com")).text(), + "Hello World" + ); + }); + + after(async () => { + await worker.dispose(); + }); +}); diff --git a/fixtures/miniflare-node-test/src/index.ts b/fixtures/miniflare-node-test/src/index.ts new file mode 100644 index 0000000..2f3566a --- /dev/null +++ b/fixtures/miniflare-node-test/src/index.ts @@ -0,0 +1,10 @@ +import isEven from "is-even"; + +export default { + async fetch(request): Promise<Response> { + const url = new URL(request.url); + return new Response( + isEven(Number(url.searchParams.get("number") ?? "0")) ? "even" : "odd" + ); + }, +} satisfies ExportedHandler; diff --git a/fixtures/miniflare-node-test/src/say-hello.js b/fixtures/miniflare-node-test/src/say-hello.js new file mode 100644 index 0000000..79451b8 --- /dev/null +++ b/fixtures/miniflare-node-test/src/say-hello.js @@ -0,0 +1,8 @@ +/** + * + * @param {string} url + * @returns + */ +export function sayHello(url) { + return `Hello from ${url}`; +} diff --git a/fixtures/miniflare-node-test/tsconfig.json b/fixtures/miniflare-node-test/tsconfig.json new file mode 100644 index 0000000..76e6c38 --- /dev/null +++ b/fixtures/miniflare-node-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"], + "checkJs": true + }, + "include": ["**/*.js", "**/*.ts"] +} diff --git a/fixtures/miniflare-node-test/wrangler-build.json b/fixtures/miniflare-node-test/wrangler-build.json new file mode 100644 index 0000000..46ed31d --- /dev/null +++ b/fixtures/miniflare-node-test/wrangler-build.json @@ -0,0 +1,5 @@ +{ + "name": "build-miniflare", + "main": "src/index.ts", + "compatibility_date": "2025-01-16" +} diff --git a/fixtures/miniflare-node-test/wrangler.jsonc b/fixtures/miniflare-node-test/wrangler.jsonc new file mode 100644 index 0000000..74fcb98 --- /dev/null +++ b/fixtures/miniflare-node-test/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "miniflare-node-test", + "main": "dist/out.js", + "compatibility_date": "2022-12-08", + "build": { + "command": "npx esbuild src/index.js --outfile=dist/out.js", + }, +} diff --git a/fixtures/multi-worker/package.json b/fixtures/multi-worker/package.json new file mode 100644 index 0000000..bdaffe6 --- /dev/null +++ b/fixtures/multi-worker/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/multi-worker", + "private": true, + "scripts": { + "cf-typegen": "pnpm run \"/^typegen:.*/\"", + "deploy": "wrangler deploy", + "start": "wrangler dev -c workers/sentry/wrangler.jsonc -c workers/default/wrangler.jsonc", + "test:ci": "vitest run", + "test:watch": "vitest", + "typegen:default": "wrangler types ./workers/default/worker-configuration.d.ts -c ./workers/default/wrangler.jsonc --no-include-runtime", + "typegen:sentry": "wrangler types ./workers/sentry/worker-configuration.d.ts -c ./workers/sentry/wrangler.jsonc --no-include-runtime" + }, + "dependencies": { + "@sentry/cloudflare": "^10" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/multi-worker/tests/multi-worker.test.ts b/fixtures/multi-worker/tests/multi-worker.test.ts new file mode 100644 index 0000000..f2a8223 --- /dev/null +++ b/fixtures/multi-worker/tests/multi-worker.test.ts @@ -0,0 +1,21 @@ +import * as path from "node:path"; +import { describe, it, vi } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Multi Worker", () => { + it("can start a multi worker application with Sentry", async ({ expect }) => { + const { ip, port, stop } = await runWranglerDev( + path.resolve(__dirname, ".."), + ["-c=workers/sentry/wrangler.jsonc", "-c=workers/default/wrangler.jsonc"] + ); + try { + await vi.waitFor(async () => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toBe(`Hello World!`); + }); + } finally { + await stop(); + } + }); +}); diff --git a/fixtures/multi-worker/vitest.config.mts b/fixtures/multi-worker/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/multi-worker/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/multi-worker/workers/default/src/index.ts b/fixtures/multi-worker/workers/default/src/index.ts new file mode 100644 index 0000000..e1286c9 --- /dev/null +++ b/fixtures/multi-worker/workers/default/src/index.ts @@ -0,0 +1,6 @@ +export default { + async fetch(request, env, ctx): Promise<Response> { + console.log("Hello World!"); + return new Response("Hello World!"); + }, +} satisfies ExportedHandler<Env>; diff --git a/fixtures/multi-worker/workers/default/tsconfig.json b/fixtures/multi-worker/workers/default/tsconfig.json new file mode 100644 index 0000000..52b1f79 --- /dev/null +++ b/fixtures/multi-worker/workers/default/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2024", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2024"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["test"], + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/fixtures/multi-worker/workers/default/worker-configuration.d.ts b/fixtures/multi-worker/workers/default/worker-configuration.d.ts new file mode 100644 index 0000000..4ddaf76 --- /dev/null +++ b/fixtures/multi-worker/workers/default/worker-configuration.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./workers/default/worker-configuration.d.ts -c ./workers/default/wrangler.jsonc --no-include-runtime` (hash: 3d3fdd145760295159fc1eac24135779) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/multi-worker/workers/default/wrangler.jsonc b/fixtures/multi-worker/workers/default/wrangler.jsonc new file mode 100644 index 0000000..e45361f --- /dev/null +++ b/fixtures/multi-worker/workers/default/wrangler.jsonc @@ -0,0 +1,14 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "b", + "main": "src/index.ts", + "compatibility_date": "2026-01-01", + "compatibility_flags": ["nodejs_als"], + "observability": { + "enabled": true, + }, +} diff --git a/fixtures/multi-worker/workers/sentry/src/index.ts b/fixtures/multi-worker/workers/sentry/src/index.ts new file mode 100644 index 0000000..c37a481 --- /dev/null +++ b/fixtures/multi-worker/workers/sentry/src/index.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/cloudflare"; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: "https://foo@sentry.com/123456", + }), + { + async fetch(request, env, ctx): Promise<Response> { + console.log("Hello World!"); + return new Response("Hello World!"); + }, + } satisfies ExportedHandler<Env> +); diff --git a/fixtures/multi-worker/workers/sentry/tsconfig.json b/fixtures/multi-worker/workers/sentry/tsconfig.json new file mode 100644 index 0000000..52b1f79 --- /dev/null +++ b/fixtures/multi-worker/workers/sentry/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2024", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2024"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["test"], + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/fixtures/multi-worker/workers/sentry/worker-configuration.d.ts b/fixtures/multi-worker/workers/sentry/worker-configuration.d.ts new file mode 100644 index 0000000..d753bc9 --- /dev/null +++ b/fixtures/multi-worker/workers/sentry/worker-configuration.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./workers/sentry/worker-configuration.d.ts -c ./workers/sentry/wrangler.jsonc --no-include-runtime` (hash: 3d3fdd145760295159fc1eac24135779) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/multi-worker/workers/sentry/wrangler.jsonc b/fixtures/multi-worker/workers/sentry/wrangler.jsonc new file mode 100644 index 0000000..ac537c6 --- /dev/null +++ b/fixtures/multi-worker/workers/sentry/wrangler.jsonc @@ -0,0 +1,14 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "a", + "main": "src/index.ts", + "compatibility_date": "2026-01-01", + "compatibility_flags": ["nodejs_als"], + "observability": { + "enabled": true, + }, +} diff --git a/fixtures/no-bundle-import/CHANGELOG.md b/fixtures/no-bundle-import/CHANGELOG.md new file mode 100644 index 0000000..258111a --- /dev/null +++ b/fixtures/no-bundle-import/CHANGELOG.md @@ -0,0 +1,77 @@ +# no-bundle-import + +## 0.0.1 + +### Patch Changes + +- [#2769](https://github.com/cloudflare/workers-sdk/pull/2769) [`0a779904`](https://github.com/cloudflare/workers-sdk/commit/0a77990457652af36c60c52bf9c38c3a69945de4) Thanks [@penalosa](https://github.com/penalosa)! - feature: Support modules with `--no-bundle` + + When the `--no-bundle` flag is set, Wrangler now has support for uploading additional modules alongside the entrypoint. This will allow modules to be imported at runtime on Cloudflare's Edge. This respects Wrangler's [module rules](https://developers.cloudflare.com/workers/wrangler/configuration/#bundling) configuration, which means that only imports of non-JS modules will trigger an upload by default. For instance, the following code will now work with `--no-bundle` (assuming the `example.wasm` file exists at the correct path): + + ```js + // index.js + import wasm from './example.wasm' + + export default { + async fetch() { + await WebAssembly.instantiate(wasm, ...) + ... + } + } + ``` + + For JS modules, it's necessary to specify an additional [module rule](https://developers.cloudflare.com/workers/wrangler/configuration/#bundling) (or rules) in your `wrangler.toml` to configure your modules as ES modules or Common JS modules. For instance, to upload additional JavaScript files as ES modules, add the following module rule to your `wrangler.toml`, which tells Wrangler that all `**/*.js` files are ES modules. + + ```toml + rules = [ + { type = "ESModule", globs = ["**/*.js"]}, + ] + ``` + + If you have Common JS modules, you'd configure Wrangler with a CommonJS rule (the following rule tells Wrangler that all `.cjs` files are Common JS modules): + + ```toml + rules = [ + { type = "CommonJS", globs = ["**/*.cjs"]}, + ] + ``` + + In most projects, adding a single rule will be sufficient. However, for advanced usecases where you're mixing ES modules and Common JS modules, you'll need to use multiple rule definitions. For instance, the following set of rules will match all `.mjs` files as ES modules, all `.cjs` files as Common JS modules, and the `nested/say-hello.js` file as Common JS. + + ```toml + rules = [ + { type = "CommonJS", globs = ["nested/say-hello.js", "**/*.cjs"]}, + { type = "ESModule", globs = ["**/*.mjs"]} + ] + ``` + + If multiple rules overlap, Wrangler will log a warning about the duplicate rules, and will discard additional rules that matches a module. For example, the following rule configuration classifies `dep.js` as both a Common JS module and an ES module: + + ```toml + rules = [ + { type = "CommonJS", globs = ["dep.js"]}, + { type = "ESModule", globs = ["dep.js"]} + ] + ``` + + Wrangler will treat `dep.js` as a Common JS module, since that was the first rule that matched, and will log the following warning: + + ``` + ▲ [WARNING] Ignoring duplicate module: dep.js (esm) + ``` + + This also adds a new configuration option to `wrangler.toml`: `base_dir`. Defaulting to the directory of your Worker's main entrypoint, this tells Wrangler where your additional modules are located, and determines the module paths against which your module rule globs are matched. + + For instance, given the following directory structure: + + ``` + - wrangler.toml + - src/ + - index.html + - vendor/ + - dependency.js + - js/ + - index.js + ``` + + If your `wrangler.toml` had `main = "src/js/index.js"`, you would need to set `base_dir = "src"` in order to be able to import `src/vendor/dependency.js` and `src/index.html` from `src/js/index.js`. diff --git a/fixtures/no-bundle-import/README.md b/fixtures/no-bundle-import/README.md new file mode 100644 index 0000000..bad6c1a --- /dev/null +++ b/fixtures/no-bundle-import/README.md @@ -0,0 +1,3 @@ +# `no-bundle-import` + +This worker exercises the module collection system when `--no-bundle` is specified. It demonstrates dynamic import and static import, as well as module rules whcih treat different JS files as CommonJS or ESModule diff --git a/fixtures/no-bundle-import/package.json b/fixtures/no-bundle-import/package.json new file mode 100644 index 0000000..246a5b5 --- /dev/null +++ b/fixtures/no-bundle-import/package.json @@ -0,0 +1,15 @@ +{ + "name": "@fixture/no-bundle-import", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "get-port": "^7.0.0", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/no-bundle-import/src/data.bin b/fixtures/no-bundle-import/src/data.bin new file mode 100644 index 0000000..2ba219b Binary files /dev/null and b/fixtures/no-bundle-import/src/data.bin differ diff --git a/fixtures/no-bundle-import/src/data.txt b/fixtures/no-bundle-import/src/data.txt new file mode 100644 index 0000000..1aa4b5d --- /dev/null +++ b/fixtures/no-bundle-import/src/data.txt @@ -0,0 +1 @@ +TEST DATA \ No newline at end of file diff --git a/fixtures/no-bundle-import/src/dynamic.cjs b/fixtures/no-bundle-import/src/dynamic.cjs new file mode 100644 index 0000000..6cfc3dd --- /dev/null +++ b/fixtures/no-bundle-import/src/dynamic.cjs @@ -0,0 +1 @@ +module.exports = "cjs-string"; diff --git a/fixtures/no-bundle-import/src/dynamic.js b/fixtures/no-bundle-import/src/dynamic.js new file mode 100644 index 0000000..6c40343 --- /dev/null +++ b/fixtures/no-bundle-import/src/dynamic.js @@ -0,0 +1 @@ +export default "dynamic"; diff --git a/fixtures/no-bundle-import/src/index.js b/fixtures/no-bundle-import/src/index.js new file mode 100644 index 0000000..5557de3 --- /dev/null +++ b/fixtures/no-bundle-import/src/index.js @@ -0,0 +1,74 @@ +import binData from "./data.bin"; +import text from "./data.txt"; +import { johnSmith } from "./nested/index.js"; +import nestedWasm from "./nested/simple.wasm"; +import cjs from "./say-hello.cjs"; +import { sayHello } from "./say-hello.js"; +import WASM from "./simple.wasm"; + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname === "/dynamic") { + return new Response(`${(await import("./dynamic.js")).default}`); + } + if (url.pathname === "/wasm") { + return new Response( + await new Promise(async (resolve) => { + const moduleImport = { + imports: { + imported_func(arg) { + resolve(arg); + }, + }, + }; + const module1 = await WebAssembly.instantiate(WASM, moduleImport); + module1.exports.exported_func(); + }) + ); + } + if (url.pathname === "/wasm-nested") { + return new Response( + await new Promise(async (resolve) => { + const moduleImport = { + imports: { + imported_func(arg) { + resolve("nested" + arg); + }, + }, + }; + const m = await WebAssembly.instantiate(nestedWasm, moduleImport); + m.exports.exported_func(); + }) + ); + } + if (url.pathname === "/wasm-dynamic") { + return new Response( + `${await (await import("./nested/index.js")).loadWasm()}` + ); + } + + if (url.pathname.startsWith("/lang")) { + const language = url.pathname.split("/lang/")[1]; + return new Response( + `${JSON.parse((await import(`./lang/${language}`)).default).hello}` + ); + } + + if (url.pathname === "/txt") { + return new Response(text); + } + if (url.pathname === "/bin") { + return new Response(binData); + } + if (url.pathname === "/cjs") { + return new Response( + `CJS: ${cjs.sayHello("Jane Smith")} and ${johnSmith}` + ); + } + if (url.pathname === "/cjs-loop") { + return new Response(`CJS: ${cjs.loop}`); + } + return new Response(`${sayHello("Jane Smith")} and ${johnSmith}`); + }, +}; diff --git a/fixtures/no-bundle-import/src/index.test.ts b/fixtures/no-bundle-import/src/index.test.ts new file mode 100644 index 0000000..d1b6e8d --- /dev/null +++ b/fixtures/no-bundle-import/src/index.test.ts @@ -0,0 +1,88 @@ +import path from "path"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { createTestHarness } from "wrangler"; + +describe("Worker", () => { + const server = createTestHarness({ + root: path.resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); + + beforeAll(async () => { + await server.listen(); + }, 30_000); + + afterAll(() => server.close()); + + test("module traversal results in correct response", async ({ expect }) => { + const resp = await server.fetch("/"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot( + `"Hello Jane Smith and Hello John Smith"` + ); + }); + + test("module traversal results in correct response for CommonJS", async ({ + expect, + }) => { + const resp = await server.fetch("/cjs"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot( + `"CJS: Hello Jane Smith and Hello John Smith"` + ); + }); + + test("correct response for CommonJS which imports ESM", async ({ + expect, + }) => { + const resp = await server.fetch("/cjs-loop"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"CJS: cjs-string"'); + }); + + test("support for dynamic imports", async ({ expect }) => { + const resp = await server.fetch("/dynamic"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot(`"dynamic"`); + }); + + test("basic wasm support", async ({ expect }) => { + const resp = await server.fetch("/wasm"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"42"'); + }); + + test("resolves wasm import paths relative to root", async ({ expect }) => { + const resp = await server.fetch("/wasm-nested"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"nested42"'); + }); + + test("wasm can be imported from a dynamic import", async ({ expect }) => { + const resp = await server.fetch("/wasm-dynamic"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"sibling42subdirectory42"'); + }); + + test("text data can be imported", async ({ expect }) => { + const resp = await server.fetch("/txt"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"TEST DATA"'); + }); + + test("binary data can be imported", async ({ expect }) => { + const resp = await server.fetch("/bin"); + const bin = await resp.arrayBuffer(); + const expected = new Uint8Array(new ArrayBuffer(4)); + expected.set([0, 1, 2, 10]); + expect(new Uint8Array(bin)).toEqual(expected); + }); + + test("actual dynamic import (that cannot be inlined by an esbuild run)", async ({ + expect, + }) => { + const resp = await server.fetch("/lang/fr.json"); + const text = await resp.text(); + expect(text).toMatchInlineSnapshot('"Bonjour"'); + }); +}); diff --git a/fixtures/no-bundle-import/src/lang/en.json b/fixtures/no-bundle-import/src/lang/en.json new file mode 100644 index 0000000..7961a72 --- /dev/null +++ b/fixtures/no-bundle-import/src/lang/en.json @@ -0,0 +1,3 @@ +{ + "hello": "Hello" +} diff --git a/fixtures/no-bundle-import/src/lang/fr.json b/fixtures/no-bundle-import/src/lang/fr.json new file mode 100644 index 0000000..8574d7a --- /dev/null +++ b/fixtures/no-bundle-import/src/lang/fr.json @@ -0,0 +1,3 @@ +{ + "hello": "Bonjour" +} diff --git a/fixtures/no-bundle-import/src/nested/index.js b/fixtures/no-bundle-import/src/nested/index.js new file mode 100644 index 0000000..238137f --- /dev/null +++ b/fixtures/no-bundle-import/src/nested/index.js @@ -0,0 +1,36 @@ +import { sayHello } from "../say-hello.js"; +import subWasm from "../simple.wasm"; +import cjs from "./say-hello.js"; +import sibWasm from "./simple.wasm"; + +export const johnSmith = + sayHello("John Smith") === cjs.sayHello("John Smith") + ? sayHello("John Smith") + : false; + +export async function loadWasm() { + const sibling = await new Promise(async (resolve) => { + const moduleImport = { + imports: { + imported_func(arg) { + resolve("sibling" + arg); + }, + }, + }; + const m = await WebAssembly.instantiate(sibWasm, moduleImport); + m.exports.exported_func(); + }); + + const subdirectory = await new Promise(async (resolve) => { + const moduleImport = { + imports: { + imported_func(arg) { + resolve("subdirectory" + arg); + }, + }, + }; + const m = await WebAssembly.instantiate(subWasm, moduleImport); + m.exports.exported_func(); + }); + return sibling + subdirectory; +} diff --git a/fixtures/no-bundle-import/src/nested/say-hello.js b/fixtures/no-bundle-import/src/nested/say-hello.js new file mode 100644 index 0000000..3dc2add --- /dev/null +++ b/fixtures/no-bundle-import/src/nested/say-hello.js @@ -0,0 +1 @@ +module.exports.sayHello = (name) => `Hello ${name}`; diff --git a/fixtures/no-bundle-import/src/nested/simple.wasm b/fixtures/no-bundle-import/src/nested/simple.wasm new file mode 100644 index 0000000..a2e9ad6 Binary files /dev/null and b/fixtures/no-bundle-import/src/nested/simple.wasm differ diff --git a/fixtures/no-bundle-import/src/say-hello.cjs b/fixtures/no-bundle-import/src/say-hello.cjs new file mode 100644 index 0000000..bb97cd1 --- /dev/null +++ b/fixtures/no-bundle-import/src/say-hello.cjs @@ -0,0 +1,3 @@ +module.exports.sayHello = (name) => `Hello ${name}`; + +module.exports.loop = require("./dynamic.cjs"); diff --git a/fixtures/no-bundle-import/src/say-hello.js b/fixtures/no-bundle-import/src/say-hello.js new file mode 100644 index 0000000..722d080 --- /dev/null +++ b/fixtures/no-bundle-import/src/say-hello.js @@ -0,0 +1 @@ +export const sayHello = (name) => `Hello ${name}`; diff --git a/fixtures/no-bundle-import/src/simple.wasm b/fixtures/no-bundle-import/src/simple.wasm new file mode 100644 index 0000000..a2e9ad6 Binary files /dev/null and b/fixtures/no-bundle-import/src/simple.wasm differ diff --git a/fixtures/no-bundle-import/vitest.config.mts b/fixtures/no-bundle-import/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/no-bundle-import/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/no-bundle-import/wrangler.jsonc b/fixtures/no-bundle-import/wrangler.jsonc new file mode 100644 index 0000000..0dcfa3e --- /dev/null +++ b/fixtures/no-bundle-import/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "name": "no-bundle-import", + "main": "src/index.js", + "compatibility_date": "2023-02-20", + "no_bundle": true, + "rules": [ + { + "type": "CommonJS", + "globs": ["nested/say-hello.js", "**/*.cjs"], + }, + { + "type": "ESModule", + "globs": ["**/*.js"], + }, + { + "type": "Text", + "globs": ["**/*.json"], + "fallthrough": true, + }, + ], +} diff --git a/fixtures/node-app-pages/.gitignore b/fixtures/node-app-pages/.gitignore new file mode 100644 index 0000000..79451db --- /dev/null +++ b/fixtures/node-app-pages/.gitignore @@ -0,0 +1 @@ +cdn-cgi/ diff --git a/fixtures/node-app-pages/functions/stripe.ts b/fixtures/node-app-pages/functions/stripe.ts new file mode 100644 index 0000000..34e9703 --- /dev/null +++ b/fixtures/node-app-pages/functions/stripe.ts @@ -0,0 +1,12 @@ +import path from "path"; +import Stripe from "stripe"; + +export const onRequest = () => { + // make sure path actually works + return new Response( + JSON.stringify({ + PATH: path.join("path/to", "some-file"), + STRIPE_OBJECT: Stripe.toString(), + }) + ); +}; diff --git a/fixtures/node-app-pages/package.json b/fixtures/node-app-pages/package.json new file mode 100644 index 0000000..0172e5a --- /dev/null +++ b/fixtures/node-app-pages/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fixture/pages-node-app", + "private": true, + "sideEffects": false, + "main": "dist/worker.js", + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 12345", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "stripe": "^9.1.0" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/node-app-pages/public/index.html b/fixtures/node-app-pages/public/index.html new file mode 100644 index 0000000..1520bf7 --- /dev/null +++ b/fixtures/node-app-pages/public/index.html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <body> + <h1>Hello, world!</h1> + </body> +</html> diff --git a/fixtures/node-app-pages/tests/index.test.ts b/fixtures/node-app-pages/tests/index.test.ts new file mode 100644 index 0000000..7d273f2 --- /dev/null +++ b/fixtures/node-app-pages/tests/index.test.ts @@ -0,0 +1,30 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Dev", () => { + it("should work with `nodejs_compat` when running code requiring polyfills", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + [ + "--port=0", + "--inspector-port=0", + "--compatibility-flags=nodejs_compat", + "--compatibility-date=2024-11-01", + ] + ); + try { + const response = await fetch(`http://${ip}:${port}/stripe`); + + await expect(response.text()).resolves.toContain( + `"PATH":"path/to/some-file","STRIPE_OBJECT"` + ); + } finally { + await stop(); + } + }); +}); diff --git a/fixtures/node-app-pages/tests/tsconfig.json b/fixtures/node-app-pages/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/node-app-pages/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/node-app-pages/tsconfig.json b/fixtures/node-app-pages/tsconfig.json new file mode 100644 index 0000000..059b256 --- /dev/null +++ b/fixtures/node-app-pages/tsconfig.json @@ -0,0 +1,13 @@ +{ + "include": ["functions"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types/experimental"], + "moduleResolution": "node", + "skipLibCheck": true, + "esModuleInterop": true, + "noEmit": true + } +} diff --git a/fixtures/node-app-pages/vitest.config.mts b/fixtures/node-app-pages/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/node-app-pages/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/node-env/package.json b/fixtures/node-env/package.json new file mode 100644 index 0000000..85c3b1d --- /dev/null +++ b/fixtures/node-env/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fixture/node-env", + "private": true, + "scripts": { + "check:type": "tsc", + "dev": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/node": "catalog:default", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.2.0", + "miniflare": "workspace:*", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/node-env/src/index.ts b/fixtures/node-env/src/index.ts new file mode 100644 index 0000000..437bfbc --- /dev/null +++ b/fixtures/node-env/src/index.ts @@ -0,0 +1,20 @@ +import React from "react"; +import { renderToString } from "react-dom/server"; + +export default { + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname === "/ssr") { + const content = renderToString( + React.createElement("h1", null, "Hello world") + ); + + return new Response(content); + } + + return new Response( + `The value of process.env.NODE_ENV is "${process.env.NODE_ENV}"` + ); + }, +} satisfies ExportedHandler; diff --git a/fixtures/node-env/tests/node-env.test.ts b/fixtures/node-env/tests/node-env.test.ts new file mode 100644 index 0000000..c7f53dc --- /dev/null +++ b/fixtures/node-env/tests/node-env.test.ts @@ -0,0 +1,137 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Miniflare } from "miniflare"; +import { describe, it, vi } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("`process.env.NODE_ENV` replacement in development", () => { + it("replaces `process.env.NODE_ENV` with `development` if it is `undefined`", async ({ + expect, + }) => { + vi.stubEnv("NODE_ENV", undefined); + + const { ip, port, stop } = await runWranglerDev( + path.resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + ); + + await vi.waitFor(async () => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toBe(`The value of process.env.NODE_ENV is "development"`); + }); + + await stop(); + + vi.unstubAllEnvs(); + }); + + it("replaces `process.env.NODE_ENV` with the given value if it is set", async ({ + expect, + }) => { + vi.stubEnv("NODE_ENV", "some-value"); + + const { ip, port, stop } = await runWranglerDev( + path.resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + ); + + await vi.waitFor(async () => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toBe(`The value of process.env.NODE_ENV is "some-value"`); + }); + + await stop(); + + vi.unstubAllEnvs(); + }); +}); + +describe("`process.env.NODE_ENV` replacement in production", () => { + const url = "http://localhost"; + + it("replaces `process.env.NODE_ENV` with `production` if it is `undefined`", async ({ + expect, + }) => { + vi.stubEnv("NODE_ENV", undefined); + + spawnSync("npx wrangler build", { + shell: true, + stdio: "pipe", + }); + + const miniflare = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "./dist/index.js", + }, + ], + }); + + await miniflare.ready; + + await vi.waitFor(async () => { + const response = await miniflare.dispatchFetch(url); + const text = await response.text(); + expect(text).toBe(`The value of process.env.NODE_ENV is "production"`); + }); + + await miniflare.dispose(); + + vi.unstubAllEnvs(); + }); + + it("replaces `process.env.NODE_ENV` with the given value if it is set", async ({ + expect, + }) => { + vi.stubEnv("NODE_ENV", "some-value"); + + spawnSync("npx wrangler build", { + shell: true, + stdio: "pipe", + }); + + const miniflare = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "./dist/index.js", + }, + ], + }); + + await miniflare.ready; + + await vi.waitFor(async () => { + const response = await miniflare.dispatchFetch(url); + const text = await response.text(); + expect(text).toBe(`The value of process.env.NODE_ENV is "some-value"`); + }); + + await miniflare.dispose(); + + vi.unstubAllEnvs(); + }); + + it("tree shakes React when `process.env.NODE_ENV` is `production`", ({ + expect, + }) => { + vi.stubEnv("NODE_ENV", undefined); + + spawnSync("npx wrangler build", { + shell: true, + stdio: "pipe", + }); + + const outputJs = fs.readFileSync("./dist/index.js", "utf8"); + + expect(outputJs).not.toContain("react-dom.development.js"); + // the React development code links to the facebook/react repo + expect(outputJs).not.toContain("https://github.com/facebook/react"); + + vi.unstubAllEnvs(); + }); +}); diff --git a/fixtures/node-env/tsconfig.json b/fixtures/node-env/tsconfig.json new file mode 100644 index 0000000..caa95db --- /dev/null +++ b/fixtures/node-env/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental", "node"] + }, + "include": ["src", "tests"] +} diff --git a/fixtures/node-env/vitest.config.mts b/fixtures/node-env/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/node-env/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/node-env/wrangler.jsonc b/fixtures/node-env/wrangler.jsonc new file mode 100644 index 0000000..e2dd3d1 --- /dev/null +++ b/fixtures/node-env/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "node-env", + "compatibility_date": "2025-10-01", + "main": "./src/index.ts", +} diff --git a/fixtures/nodejs-als-app/package.json b/fixtures/nodejs-als-app/package.json new file mode 100644 index 0000000..be70296 --- /dev/null +++ b/fixtures/nodejs-als-app/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixture/nodejs-als", + "private": true, + "scripts": { + "build": "wrangler deploy --dry-run --outdir=./dist", + "dev": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/nodejs-als-app/src/index.ts b/fixtures/nodejs-als-app/src/index.ts new file mode 100644 index 0000000..5ab5b1b --- /dev/null +++ b/fixtures/nodejs-als-app/src/index.ts @@ -0,0 +1,24 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +const asyncLocalStorage = new AsyncLocalStorage<number>(); + +export default { + async fetch(req: Request, env: unknown, ctx: ExecutionContext) { + if (new URL(req.url).pathname !== "/") { + return new Response("No Found", { status: 404 }); + } + + const results = await Promise.all([ + asyncLocalStorage.run(1, async () => getValue()), + asyncLocalStorage.run(2, async () => getValue()), + asyncLocalStorage.run(3, async () => getValue()), + ]); + + return new Response(`Working ${JSON.stringify(results)}`); + }, +}; + +async function getValue() { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return asyncLocalStorage.getStore(); +} diff --git a/fixtures/nodejs-als-app/tests/index.test.ts b/fixtures/nodejs-als-app/tests/index.test.ts new file mode 100644 index 0000000..a320020 --- /dev/null +++ b/fixtures/nodejs-als-app/tests/index.test.ts @@ -0,0 +1,25 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("nodejs als", () => { + it("should work with node:async_hooks without full nodejs compat", async ({ + expect, + }) => { + const { ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, "../src"), + ["--port=0", "--inspector-port=0"] + ); + try { + // There should be no warning about async_hooks + expect(getOutput()).not.toContain("node:async_hooks"); + + const response = await fetch(`http://${ip}:${port}/`); + const body = await response.text(); + expect(body).toMatchInlineSnapshot(`"Working [1,2,3]"`); + } finally { + await stop(); + } + }); +}); diff --git a/fixtures/nodejs-als-app/tests/tsconfig.json b/fixtures/nodejs-als-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/nodejs-als-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/nodejs-als-app/tsconfig.json b/fixtures/nodejs-als-app/tsconfig.json new file mode 100644 index 0000000..3ffb885 --- /dev/null +++ b/fixtures/nodejs-als-app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["esnext"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "types": ["@cloudflare/workers-types/experimental", "node"], + "allowJs": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/fixtures/nodejs-als-app/turbo.json b/fixtures/nodejs-als-app/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/nodejs-als-app/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/nodejs-als-app/vitest.config.mts b/fixtures/nodejs-als-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/nodejs-als-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/nodejs-als-app/wrangler.jsonc b/fixtures/nodejs-als-app/wrangler.jsonc new file mode 100644 index 0000000..ff506b3 --- /dev/null +++ b/fixtures/nodejs-als-app/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "nodejs-als-app", + "main": "src/index.ts", + "compatibility_date": "2024-08-03", + "compatibility_flags": ["nodejs_als"], +} diff --git a/fixtures/nodejs-hybrid-app/.env b/fixtures/nodejs-hybrid-app/.env new file mode 100644 index 0000000..d81f8ff --- /dev/null +++ b/fixtures/nodejs-hybrid-app/.env @@ -0,0 +1 @@ +DEV_VAR_FROM_DOT_ENV="dev-var-from-dot-env" \ No newline at end of file diff --git a/fixtures/nodejs-hybrid-app/.gitignore b/fixtures/nodejs-hybrid-app/.gitignore new file mode 100644 index 0000000..1e18f27 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/.gitignore @@ -0,0 +1 @@ +!.env \ No newline at end of file diff --git a/fixtures/nodejs-hybrid-app/package.json b/fixtures/nodejs-hybrid-app/package.json new file mode 100644 index 0000000..04bd408 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fixture/nodejs-hybrid", + "private": true, + "scripts": { + "build": "wrangler deploy --dry-run --outdir=./dist", + "dev": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/debug": "4.1.12", + "@types/pg": "^8.11.2", + "debug": "4.4.1", + "pg": "8.16.3", + "pg-cloudflare": "^1.2.7", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/nodejs-hybrid-app/src/dep.cjs b/fixtures/nodejs-hybrid-app/src/dep.cjs new file mode 100644 index 0000000..e1a3189 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/src/dep.cjs @@ -0,0 +1,4 @@ +const Stream = require("stream"); + +const s = new Stream(); +module.exports.s = s; diff --git a/fixtures/nodejs-hybrid-app/src/index.ts b/fixtures/nodejs-hybrid-app/src/index.ts new file mode 100644 index 0000000..a15e789 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/src/index.ts @@ -0,0 +1,354 @@ +import nodeCrypto, { getRandomValues, webcrypto } from "crypto"; +// node:assert/strict is currently an unenv alias to node:assert +// this is not very common, but happens and we need to support it +import assert from "node:assert/strict"; +import { Stream } from "node:stream"; +import { Context } from "vm"; +import { Client } from "pg"; +import { s } from "./dep.cjs"; + +testBasicNodejsProperties(); + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise<Response> { + const url = new URL(request.url); + + switch (url.pathname) { + case "/test-random": + return testGetRandomValues(); + case "/test-process": + return testProcessBehavior(); + case "/env": + return Response.json(env); + case "/process-env": + return Response.json(process.env); + case "/query": + return testPostgresLibrary(env, ctx); + case "/test-x509-certificate": + return testX509Certificate(); + case "/test-require-alias": + return testRequireUnenvAliasedPackages(); + case "/test-immediate": + return await testImmediate(); + case "/test-tls": + return await testTls(); + case "/test-crypto": + return await testCrypto(); + case "/test-sqlite": + return await testSqlite(); + case "/test-http": + return await testHttp(); + case "/test-debug-import": + return await testDebugImport(); + case "/test-debug-require": + return await testDebugRequire(); + } + + return new Response( + `<a href="query">Postgres query</a> +<a href="test-process">Test process global</a> +<a href="test-random">Test getRandomValues()</a> +<a href="test-x509-certificate">Test X509Certificate</a> +<a href="test-require-alias">Test require unenv aliased packages</a> +<a href="test-immediate">Test setImmediate</a> +<a href="test-tls">node:tls</a> +<a href="test-crypto">node:crypto</a> +<a href="test-sqlite">node:sqlite</a> +<a href="test-http">node:http</a> +<a href="test-debug-import">debug (import)</a> +<a href="test-debug-require">debug (require)</a> +`, + { headers: { "Content-Type": "text/html; charset=utf-8" } } + ); + }, +}; + +async function testImmediate() { + try { + await new Promise((resolve, reject) => { + // Give it a whole second otherwise it times-out + setTimeout(reject, 1000); + + // This setImmediate should never trigger the reject if the clearImmediate is working + const id = setImmediate(reject); + // clearImmediate should cancel reject callback + clearImmediate(id); + // This setImmediate should trigger the resolve callback + setImmediate(resolve); + }); + + return new Response("OK"); + } catch (e) { + return new Response(`NOT OK: ${e}`); + } +} + +function testRequireUnenvAliasedPackages() { + const fetch = require("cross-fetch"); + const supportsDefaultExports = typeof fetch === "function"; + const supportsNamedExports = typeof fetch.Headers === "function"; + return new Response( + supportsDefaultExports && supportsNamedExports ? `"OK!"` : `"KO!"` + ); +} + +function testX509Certificate() { + try { + new nodeCrypto.X509Certificate(`-----BEGIN CERTIFICATE----- +MIICZjCCAc+gAwIBAgIUOsv8Y+x40C+gdNuu40N50KpGUhEwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA5MjAwOTA4MTNaFw0yNTA5 +MjAwOTA4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwgZ8wDQYJKoZIhvcNAQEB +BQADgY0AMIGJAoGBALpJn3dUrNmZhZV02RbjZKTd5j3hpgTncF4lG4Y3sQA18k0l +7pt6xpZuXYSFH7v2zTAxYy+uYyYwX2NZur48dZc76FSzIeuQdoTCkT0NacwFRTR5 +fEEqPvvB85ozYuyk8Bl3vSsonivOH3WftEDp9mjkHROQzS4wAZbIj7Cp+is/AgMB +AAGjUzBRMB0GA1UdDgQWBBSzFJSiPAw2tJOg8oUXrFBdqWI6zDAfBgNVHSMEGDAW +gBSzFJSiPAw2tJOg8oUXrFBdqWI6zDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4GBACbto0+Ds40F7faRFFMwg5nPyh7gsiX+ZK3FYcrO3oxh5ejfzwow +DKOOje4Ncaw0rIkVpxacPyjg+wANuK2Nv/Z4CVAD3mneE4gwgRdn38q8IYN9AtSv +GzEf4UxiLBbUB6WRBgyVyquGfUMlKl/tnm4q0yeYQloYKSoHpGeHVJuN +-----END CERTIFICATE-----`); + return new Response(`"OK!"`); + } catch { + return new Response(`"KO!"`); + } +} + +function testGetRandomValues() { + assert.strictEqual(nodeCrypto.getRandomValues, getRandomValues); + + return Response.json([ + crypto.getRandomValues(new Uint8Array(6)).toString(), // global + webcrypto.getRandomValues(new Uint8Array(6)).toString(), // webcrypto + nodeCrypto.getRandomValues(new Uint8Array(6)).toString(), // namespace import + getRandomValues(new Uint8Array(6)).toString(), // named import + ]); +} + +function testBasicNodejsProperties() { + assert(s instanceof Stream, "expected s to be an instance of Stream"); + + const buffer1 = Buffer.of(1); + assert.strictEqual(buffer1.toJSON().data[0], 1); + + const buffer2 = global.Buffer.of(1); + assert.strictEqual(buffer2.toJSON().data[0], 1); + + const buffer3 = globalThis.Buffer.of(1); + assert.strictEqual(buffer3.toJSON().data[0], 1); + + assert.notEqual(performance, undefined); + assert.strictEqual(global.performance, performance); + assert.strictEqual(globalThis.performance, performance); + + assert.notEqual(Performance, undefined); + assert.strictEqual(global.Performance, Performance); + assert.strictEqual(globalThis.Performance, Performance); + + assert.strictEqual(typeof performance.measure, "function"); + assert.strictEqual(typeof performance.clearMarks, "function"); +} + +function testProcessBehavior() { + assert.strictEqual(typeof process.version, "string"); + assert.strictEqual(typeof process.versions.node, "string"); + + const originalProcess = process; + try { + assert.notEqual(process, undefined); + assert.strictEqual(globalThis.process, process); + assert.strictEqual(global.process, process); + + const fakeProcess1 = {} as typeof process; + process = fakeProcess1; + assert.strictEqual(process, fakeProcess1); + assert.strictEqual(global.process, fakeProcess1); + assert.strictEqual(globalThis.process, fakeProcess1); + + const fakeProcess2 = {} as typeof process; + global.process = fakeProcess2; + assert.strictEqual(process, fakeProcess2); + assert.strictEqual(global.process, fakeProcess2); + assert.strictEqual(globalThis.process, fakeProcess2); + + const fakeProcess3 = {} as typeof process; + globalThis.process = fakeProcess3; + assert.strictEqual(process, fakeProcess3); + assert.strictEqual(global.process, fakeProcess3); + assert.strictEqual(globalThis.process, fakeProcess3); + + const fakeProcess4 = {} as typeof process; + globalThis["process"] = fakeProcess4; + assert.strictEqual(process, fakeProcess4); + assert.strictEqual(global.process, fakeProcess4); + assert.strictEqual(globalThis.process, fakeProcess4); + } catch (e) { + if (e instanceof Error) { + return new Response(`${e.stack}`, { status: 500 }); + } else { + throw e; + } + } finally { + process = originalProcess; + } + + return new Response("OK!"); +} + +async function testPostgresLibrary(env: Env, ctx: Context) { + const client = new Client({ + user: env.DB_USERNAME, + password: env.DB_PASSWORD, + host: env.DB_HOSTNAME, + port: Number(env.DB_PORT), + database: env.DB_NAME, + }); + await client.connect(); + const result = await client.query( + `SELECT * FROM rnacen.rnc_database LIMIT 5` + ); + // Return the first row as JSON + const resp = new Response(JSON.stringify(result.rows[0]), { + headers: { "Content-Type": "application/json" }, + }); + + // Clean up the client + ctx.waitUntil(client.end()); + return resp; +} + +async function testTls() { + const tls = await import("node:tls"); + + assert.strictEqual(typeof tls.connect, "function"); + assert.strictEqual(typeof tls.TLSSocket, "function"); + assert.strictEqual(typeof tls.checkServerIdentity, "function"); + assert.strictEqual( + tls.checkServerIdentity("a.com", { subject: { CN: "a.com" } }), + undefined + ); + assert.strictEqual(typeof tls.SecureContext, "function"); + assert.strictEqual(typeof tls.createSecureContext, "function"); + assert.strictEqual( + tls.createSecureContext({}) instanceof tls.SecureContext, + true + ); + + assert.strictEqual(typeof tls.convertALPNProtocols, "function"); + + return new Response("OK"); +} + +async function testCrypto() { + const crypto = await import("node:crypto"); + + const test = { name: "aes-128-cbc", size: 16, iv: 16 }; + + const key = crypto.createSecretKey(Buffer.alloc(test.size)); + const iv = Buffer.alloc(test.iv); + + const cipher = crypto.createCipheriv(test.name, key, iv); + const decipher = crypto.createDecipheriv(test.name, key, iv); + + let data = ""; + data += decipher.update(cipher.update("Hello World", "utf8")); + data += decipher.update(cipher.final()); + data += decipher.final(); + assert.strictEqual(data, "Hello World"); + + assert.strictEqual(crypto.constants.DH_UNABLE_TO_CHECK_GENERATOR, 4); + assert.strictEqual(crypto.constants.RSA_PSS_SALTLEN_DIGEST, -1); + assert.strictEqual( + crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION, + 262144 + ); + assert.strictEqual(crypto.constants.SSL_OP_NO_TICKET, 16384); + + return new Response("OK"); +} + +async function testSqlite() { + const sqlite = await import("node:sqlite"); + + assert.strictEqual(typeof sqlite.DatabaseSync, "function"); + + return new Response("OK"); +} + +async function testHttp() { + const http = await import("node:http"); + + const agent = new http.Agent(); + assert.strictEqual(typeof agent.options, "object"); + + return new Response("OK"); +} + +async function testDebugImport() { + const debug = (await import("debug")).default; + const capturedLogs: string[] = []; + + // Override debug.log to capture output for verification + debug.log = (...args: string[]) => { + capturedLogs.push(args.join(" ")); + }; + + // Test different namespaces based on DEBUG env var: "example:*,test" + const testNamespace = debug("test"); // Should log (matches "test") + const exampleNamespace = debug("example"); // Should NOT log (doesn't match "example:*") + const exampleFooNamespace = debug("example:foo"); // Should log (matches "example:*") + + testNamespace("Test import message 1"); + exampleNamespace("Example import message (should not appear)"); + exampleFooNamespace("Example foo import message"); + + if (testNamespace.enabled) { + testNamespace("Test import enabled message"); + } + + // Strip timestamps from captured logs, keeping namespace and message + // Format: "2025-08-14T20:09:49.769Z test Test import message 1" + const logsWithoutTimestamp = capturedLogs.map((log) => { + const parts = log.split(" "); + return parts.slice(1).join(" "); // Remove timestamp, keep namespace + message + }); + + return Response.json(logsWithoutTimestamp); +} + +async function testDebugRequire() { + const debug = require("debug"); + const capturedLogs: string[] = []; + + // Override debug.log to capture output for verification + debug.log = (...args: string[]) => { + capturedLogs.push(args.join(" ")); + }; + + // Test different namespaces based on DEBUG env var: "example:*,test" + const testNamespace = debug("test"); // Should log (matches "test") + const exampleNamespace = debug("example"); // Should NOT log (doesn't match "example:*") + const exampleFooNamespace = debug("example:foo"); // Should log (matches "example:*") + + testNamespace("Test require message 1"); + exampleNamespace("Example require message (should not appear)"); + exampleFooNamespace("Example foo require message"); + + if (testNamespace.enabled) { + testNamespace("Test require enabled message"); + } + + // Strip timestamps from captured logs, keeping namespace and message + // Format: "2025-08-14T20:09:49.769Z test Test require message 1" + const logsWithoutTimestamp = capturedLogs.map((log) => { + const parts = log.split(" "); + return parts.slice(1).join(" "); // Remove timestamp, keep namespace + message + }); + + return Response.json(logsWithoutTimestamp); +} diff --git a/fixtures/nodejs-hybrid-app/tests/index.test.ts b/fixtures/nodejs-hybrid-app/tests/index.test.ts new file mode 100644 index 0000000..f5d4448 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/tests/index.test.ts @@ -0,0 +1,140 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it, test } from "vitest"; +import { createMockPostgresServer } from "../../shared/src/mock-postgres-server"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("nodejs compat", () => { + let wrangler: Awaited<ReturnType<typeof runWranglerDev>>; + let mockPg: Awaited<ReturnType<typeof createMockPostgresServer>>; + + beforeAll(async () => { + // Start a local mock Postgres server that returns canned results + mockPg = await createMockPostgresServer({ + rows: [{ id: "1", name: "test-row" }], + }); + + wrangler = await runWranglerDev(resolve(__dirname, "../src"), [ + "--port=0", + "--inspector-port=0", + `--var`, + `DB_PORT:${mockPg.port}`, + ]); + }); + + afterAll(async () => { + await wrangler.stop(); + await mockPg.stop(); + }); + + it("should work when running code requiring polyfills", async ({ + expect, + }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-process`); + const body = await response.text(); + expect(body).toMatchInlineSnapshot(`"OK!"`); + }); + + it("should be able to call `getRandomValues()` bound to any object", async ({ + expect, + }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-random`); + const body = await response.json(); + expect(body).toEqual([ + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + ]); + }); + + test("crypto.X509Certificate is implemented", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-x509-certificate`); + await expect(response.text()).resolves.toBe(`"OK!"`); + }); + + test("import unenv aliased packages", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-require-alias`); + await expect(response.text()).resolves.toBe(`"OK!"`); + }); + + test("set/clearImmediate", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-immediate`); + await expect(response.text()).resolves.toBe("OK"); + }); + + test("node:tls", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-tls`); + await expect(response.text()).resolves.toBe("OK"); + }); + + test("node:crypto", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-crypto`); + await expect(response.text()).resolves.toBe("OK"); + }); + + test("node:sqlite", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-sqlite`); + await expect(response.text()).resolves.toBe("OK"); + }); + + test("node:http", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-http`); + await expect(response.text()).resolves.toBe("OK"); + }); + + test("debug import", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-debug-import`); + await expect(response.json()).resolves.toEqual([ + "test Test import message 1", + "example:foo Example foo import message", + "test Test import enabled message", + ]); + }); + + test("debug require", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/test-debug-require`); + await expect(response.json()).resolves.toEqual([ + "test Test require message 1", + "example:foo Example foo require message", + "test Test require enabled message", + ]); + }); + + test("process.env contains vars", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/process-env`); + await expect(response.json()).resolves.toMatchObject({ + DB_HOSTNAME: "127.0.0.1", + DEV_VAR_FROM_DOT_ENV: "dev-var-from-dot-env", + }); + }); + + test("env contains vars", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/env`); + await expect(response.json()).resolves.toMatchObject({ + DB_HOSTNAME: "127.0.0.1", + DEV_VAR_FROM_DOT_ENV: "dev-var-from-dot-env", + }); + }); + + test("Postgres", async ({ expect }) => { + const { ip, port } = wrangler; + const response = await fetch(`http://${ip}:${port}/query`); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ id: "1" }); + }); +}); diff --git a/fixtures/nodejs-hybrid-app/tests/tsconfig.json b/fixtures/nodejs-hybrid-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/nodejs-hybrid-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/nodejs-hybrid-app/tsconfig.json b/fixtures/nodejs-hybrid-app/tsconfig.json new file mode 100644 index 0000000..c36bd33 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["esnext"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "types": [ + "@cloudflare/workers-types/experimental", + "./worker-configuration.d.ts" + ], + "allowJs": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/fixtures/nodejs-hybrid-app/turbo.json b/fixtures/nodejs-hybrid-app/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/nodejs-hybrid-app/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/nodejs-hybrid-app/vitest.config.mts b/fixtures/nodejs-hybrid-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/nodejs-hybrid-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/nodejs-hybrid-app/worker-configuration.d.ts b/fixtures/nodejs-hybrid-app/worker-configuration.d.ts new file mode 100644 index 0000000..f7c75b2 --- /dev/null +++ b/fixtures/nodejs-hybrid-app/worker-configuration.d.ts @@ -0,0 +1,9 @@ +// Generated by Wrangler by running `wrangler types` + +interface Env { + DB_HOSTNAME: string; + DB_PORT: string; + DB_NAME: string; + DB_USERNAME: string; + DB_PASSWORD: string; +} diff --git a/fixtures/nodejs-hybrid-app/wrangler.jsonc b/fixtures/nodejs-hybrid-app/wrangler.jsonc new file mode 100644 index 0000000..6bbdccf --- /dev/null +++ b/fixtures/nodejs-hybrid-app/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "name": "nodejs-hybrid-app", + "main": "src/index.ts", + // Setting compat date after 2024/09/23 means we don't need to use `nodejs_compat_v2` + // Setting compat date after 2025/04/01 means we don't need to use `nodejs_compat_populate_process_env` + "compatibility_date": "2025-07-01", + "compatibility_flags": ["nodejs_compat"], + // Default DB connection values point at 127.0.0.1. + // Tests override DB_PORT at runtime to point at a local mock Postgres server. + "vars": { + "DB_HOSTNAME": "127.0.0.1", + "DB_PORT": "5432", + "DB_NAME": "testdb", + "DB_USERNAME": "testuser", + "DB_PASSWORD": "testpassword", + "DEBUG": "example:*,test", + }, +} diff --git a/fixtures/pages-dev-proxy-with-script/_worker.js b/fixtures/pages-dev-proxy-with-script/_worker.js new file mode 100644 index 0000000..3fb22f4 --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/_worker.js @@ -0,0 +1,5 @@ +export default { + fetch() { + return new Response("hello from _worker.js"); + }, +}; diff --git a/fixtures/pages-dev-proxy-with-script/custom/script/path/index.js b/fixtures/pages-dev-proxy-with-script/custom/script/path/index.js new file mode 100644 index 0000000..a74d25b --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/custom/script/path/index.js @@ -0,0 +1,5 @@ +export default { + fetch() { + return new Response("hello from custom/script/path/index.js"); + }, +}; diff --git a/fixtures/pages-dev-proxy-with-script/package.json b/fixtures/pages-dev-proxy-with-script/package.json new file mode 100644 index 0000000..5ff2dad --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/pages-dev-proxy", + "private": true, + "sideEffects": false, + "scripts": { + "dev": "wrangler pages dev public", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-dev-proxy-with-script/tests/index.test.ts b/fixtures/pages-dev-proxy-with-script/tests/index.test.ts new file mode 100644 index 0000000..628cddb --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/tests/index.test.ts @@ -0,0 +1,75 @@ +import { ChildProcess, fork } from "node:child_process"; +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { fetch, Response } from "undici"; +import { describe, it, onTestFinished } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages dev with proxy and a script file", () => { + it("should handle requests using a script from the default _worker.js path", async ({ + expect, + }) => { + const process = await startWranglerPagesDevProxy(); + const combinedResponse = await waitUntilReady( + `http://127.0.0.1:${process.port}/` + ); + const respText = await combinedResponse.text(); + expect(respText).toMatchInlineSnapshot('"hello from _worker.js"'); + expect( + process + .getOutput() + .includes( + "Specifying a `-- <command>` or `--proxy` is deprecated and will be removed in a future version of Wrangler." + ) + ).toBeTruthy(); + + expect( + process + .getOutput() + .includes( + "On Node.js 17+, wrangler will default to fetching only the IPv6 address. Please ensure that the process listening on the port specified via `--proxy` is configured for IPv6." + ) + ).toBeTruthy(); + }); + + it("should handle requests using a script from a custom script path", async ({ + expect, + }) => { + const process = await startWranglerPagesDevProxy([ + "--script-path=custom/script/path/index.js", + ]); + const combinedResponse = await waitUntilReady( + `http://127.0.0.1:${process.port}/` + ); + const respText = await combinedResponse.text(); + expect(respText).toMatchInlineSnapshot( + '"hello from custom/script/path/index.js"' + ); + }); +}); + +async function startWranglerPagesDevProxy(extraArgs: string[] = []) { + const process = await runWranglerPagesDev( + path.resolve(__dirname, ".."), + undefined, + ["--port=0", "--proxy=9999", ...extraArgs] + ); + + onTestFinished(process.stop); + + return process; +} + +async function waitUntilReady(url: string): Promise<Response> { + let response: Response | undefined = undefined; + + while (response === undefined) { + await setTimeout(500); + + try { + response = await fetch(url); + } catch (e) {} + } + + return response as Response; +} diff --git a/fixtures/pages-dev-proxy-with-script/tests/tsconfig.json b/fixtures/pages-dev-proxy-with-script/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-dev-proxy-with-script/tsconfig.json b/fixtures/pages-dev-proxy-with-script/tsconfig.json new file mode 100644 index 0000000..570d2e9 --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/tsconfig.json @@ -0,0 +1,12 @@ +{ + "include": ["index.d.ts"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/fixtures/pages-dev-proxy-with-script/vitest.config.mts b/fixtures/pages-dev-proxy-with-script/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-dev-proxy-with-script/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-functions-app/.gitignore b/fixtures/pages-functions-app/.gitignore new file mode 100644 index 0000000..79451db --- /dev/null +++ b/fixtures/pages-functions-app/.gitignore @@ -0,0 +1 @@ +cdn-cgi/ diff --git a/fixtures/pages-functions-app/functions/_middleware.ts b/fixtures/pages-functions-app/functions/_middleware.ts new file mode 100644 index 0000000..f860d7e --- /dev/null +++ b/fixtures/pages-functions-app/functions/_middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = async ({ next }) => { + const response = await next(); + response.headers.set("x-custom", "header value"); + return response; +}; diff --git a/fixtures/pages-functions-app/functions/blog/[slug].ts b/fixtures/pages-functions-app/functions/blog/[slug].ts new file mode 100644 index 0000000..97f0175 --- /dev/null +++ b/fixtures/pages-functions-app/functions/blog/[slug].ts @@ -0,0 +1,6 @@ +export const onRequestGet: PagesFunction<unknown, "slug"> = ({ params }) => { + const { slug } = params; + return new Response(`<h1>A blog with a slug: ${slug}</h1>`, { + headers: { "content-type": "text/html" }, + }); +}; diff --git a/fixtures/pages-functions-app/functions/date.ts b/fixtures/pages-functions-app/functions/date.ts new file mode 100644 index 0000000..b964214 --- /dev/null +++ b/fixtures/pages-functions-app/functions/date.ts @@ -0,0 +1,3 @@ +export const onRequest = () => { + return new Response(new Date().toISOString()); +}; diff --git a/fixtures/pages-functions-app/functions/import-html.ts b/fixtures/pages-functions-app/functions/import-html.ts new file mode 100644 index 0000000..b37bf9e --- /dev/null +++ b/fixtures/pages-functions-app/functions/import-html.ts @@ -0,0 +1,5 @@ +import html from "../static-assets/index.html"; + +export const onRequestGet = () => { + return new Response(html, { headers: { "Content-Type": "text/html" } }); +}; diff --git a/fixtures/pages-functions-app/functions/intercept.ts b/fixtures/pages-functions-app/functions/intercept.ts new file mode 100644 index 0000000..87fb5fa --- /dev/null +++ b/fixtures/pages-functions-app/functions/intercept.ts @@ -0,0 +1,10 @@ +export const onRequest: PagesFunction = async ({ next }) => { + const response = await next(); + return new Response(response.body, { + status: response.status, + headers: { + ...Object.fromEntries(response.headers.entries()), + "x-set-from-functions": "true", + }, + }); +}; diff --git a/fixtures/pages-functions-app/functions/middleware-data/additional-data.ts b/fixtures/pages-functions-app/functions/middleware-data/additional-data.ts new file mode 100644 index 0000000..24b40eb --- /dev/null +++ b/fixtures/pages-functions-app/functions/middleware-data/additional-data.ts @@ -0,0 +1,9 @@ +export const onRequest = [ + async (context) => { + context.data.foo = "bar"; + return await context.next(); + }, + async (context) => { + return Response.json(context.data); + }, +]; diff --git a/fixtures/pages-functions-app/functions/middleware-data/bad-data.ts b/fixtures/pages-functions-app/functions/middleware-data/bad-data.ts new file mode 100644 index 0000000..6c04c3e --- /dev/null +++ b/fixtures/pages-functions-app/functions/middleware-data/bad-data.ts @@ -0,0 +1,4 @@ +export const onRequest = async (context) => { + context.data = "foo-bar"; + return await context.next(); +}; diff --git a/fixtures/pages-functions-app/functions/middleware-data/merge-data.ts b/fixtures/pages-functions-app/functions/middleware-data/merge-data.ts new file mode 100644 index 0000000..694bf2c --- /dev/null +++ b/fixtures/pages-functions-app/functions/middleware-data/merge-data.ts @@ -0,0 +1,13 @@ +export const onRequest = [ + async (context) => { + context.data = { foo: "bar" }; + return await context.next(); + }, + async (context) => { + context.data = { bar: "baz" }; + return await context.next(); + }, + async (context) => { + return Response.json(context.data); + }, +]; diff --git a/fixtures/pages-functions-app/functions/middleware-data/mutate-data.ts b/fixtures/pages-functions-app/functions/middleware-data/mutate-data.ts new file mode 100644 index 0000000..26b34b1 --- /dev/null +++ b/fixtures/pages-functions-app/functions/middleware-data/mutate-data.ts @@ -0,0 +1,13 @@ +export const onRequest = [ + async (context) => { + context.data = { foo: "bar" }; + return await context.next(); + }, + async (context) => { + context.data.bar = "baz"; + return await context.next(); + }, + async (context) => { + return Response.json(context.data); + }, +]; diff --git a/fixtures/pages-functions-app/functions/mounted-plugin/_middleware.ts b/fixtures/pages-functions-app/functions/mounted-plugin/_middleware.ts new file mode 100644 index 0000000..a0409d4 --- /dev/null +++ b/fixtures/pages-functions-app/functions/mounted-plugin/_middleware.ts @@ -0,0 +1,5 @@ +import examplePlugin from "@fixture/pages-plugin"; + +export const onRequest = examplePlugin({ + footerText: "Set from a Plugin!", +}); diff --git a/fixtures/pages-functions-app/functions/mounted-with-param/[p]/plugin/_middleware.ts b/fixtures/pages-functions-app/functions/mounted-with-param/[p]/plugin/_middleware.ts new file mode 100644 index 0000000..df50a1f --- /dev/null +++ b/fixtures/pages-functions-app/functions/mounted-with-param/[p]/plugin/_middleware.ts @@ -0,0 +1,3 @@ +import examplePlugin from "@fixture/pages-plugin"; + +export const onRequest = examplePlugin({ footerText: "Set from a Plugin!" }); diff --git a/fixtures/pages-functions-app/functions/next.ts b/fixtures/pages-functions-app/functions/next.ts new file mode 100644 index 0000000..b1d02a0 --- /dev/null +++ b/fixtures/pages-functions-app/functions/next.ts @@ -0,0 +1 @@ +export const onRequest = ({ next }) => next("/some-asset.html"); diff --git a/fixtures/pages-functions-app/functions/passThroughOnException/_middleware.ts b/fixtures/pages-functions-app/functions/passThroughOnException/_middleware.ts new file mode 100644 index 0000000..e0cb094 --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnException/_middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = ({ passThroughOnException, next }) => { + passThroughOnException(); + + return next(); +}; diff --git a/fixtures/pages-functions-app/functions/passThroughOnException/nested.ts b/fixtures/pages-functions-app/functions/passThroughOnException/nested.ts new file mode 100644 index 0000000..5ba5e42 --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnException/nested.ts @@ -0,0 +1,4 @@ +export const onRequest = ({ passThroughOnException }) => { + // @ts-expect-error expecting ReferenceError + x; +}; diff --git a/fixtures/pages-functions-app/functions/passThroughOnExceptionClosed.ts b/fixtures/pages-functions-app/functions/passThroughOnExceptionClosed.ts new file mode 100644 index 0000000..5ba5e42 --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnExceptionClosed.ts @@ -0,0 +1,4 @@ +export const onRequest = ({ passThroughOnException }) => { + // @ts-expect-error expecting ReferenceError + x; +}; diff --git a/fixtures/pages-functions-app/functions/passThroughOnExceptionOpen.ts b/fixtures/pages-functions-app/functions/passThroughOnExceptionOpen.ts new file mode 100644 index 0000000..32076dd --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnExceptionOpen.ts @@ -0,0 +1,5 @@ +export const onRequest = ({ passThroughOnException }) => { + passThroughOnException(); + // @ts-expect-error expecting ReferenceError + x; +}; diff --git a/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/_middleware.ts b/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/_middleware.ts new file mode 100644 index 0000000..a507dbd --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/_middleware.ts @@ -0,0 +1,13 @@ +export const onRequest = async ({ request, passThroughOnException, next }) => { + passThroughOnException(); + + try { + return await next(); + } catch (e) { + if (new URL(request.url).searchParams.has("catch")) { + return new Response(`Manually caught error: ${e}`); + } + + throw e; + } +}; diff --git a/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/nested.ts b/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/nested.ts new file mode 100644 index 0000000..5ba5e42 --- /dev/null +++ b/fixtures/pages-functions-app/functions/passThroughOnExceptionWithCapture/nested.ts @@ -0,0 +1,4 @@ +export const onRequest = ({ passThroughOnException }) => { + // @ts-expect-error expecting ReferenceError + x; +}; diff --git a/fixtures/pages-functions-app/functions/r2/create.ts b/fixtures/pages-functions-app/functions/r2/create.ts new file mode 100644 index 0000000..e659e43 --- /dev/null +++ b/fixtures/pages-functions-app/functions/r2/create.ts @@ -0,0 +1,8 @@ +type Env = { + bucket: R2Bucket; +}; + +export const onRequestPut: PagesFunction<Env> = async ({ env }) => { + const object = await env.bucket.put("test", "Hello world!"); + return new Response(JSON.stringify(object)); +}; diff --git a/fixtures/pages-functions-app/functions/r2/get.ts b/fixtures/pages-functions-app/functions/r2/get.ts new file mode 100644 index 0000000..f461931 --- /dev/null +++ b/fixtures/pages-functions-app/functions/r2/get.ts @@ -0,0 +1,8 @@ +type Env = { + bucket: R2Bucket; +}; + +export const onRequestGet: PagesFunction<Env> = async ({ env }) => { + const object = await env.bucket.get("test"); + return new Response(JSON.stringify(object)); +}; diff --git a/fixtures/pages-functions-app/functions/regex_chars/my-file.ts b/fixtures/pages-functions-app/functions/regex_chars/my-file.ts new file mode 100644 index 0000000..e28f548 --- /dev/null +++ b/fixtures/pages-functions-app/functions/regex_chars/my-file.ts @@ -0,0 +1 @@ +export const onRequestGet = () => new Response("My file with regex chars"); diff --git a/fixtures/pages-functions-app/functions/static/_middleware.ts b/fixtures/pages-functions-app/functions/static/_middleware.ts new file mode 100644 index 0000000..9d2758d --- /dev/null +++ b/fixtures/pages-functions-app/functions/static/_middleware.ts @@ -0,0 +1 @@ +export { onRequest } from "assets:../../static-assets"; diff --git a/fixtures/pages-functions-app/functions/variables.ts b/fixtures/pages-functions-app/functions/variables.ts new file mode 100644 index 0000000..20f9f90 --- /dev/null +++ b/fixtures/pages-functions-app/functions/variables.ts @@ -0,0 +1,5 @@ +export const onRequest = ({ env }) => { + return new Response(JSON.stringify(env), { + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/fixtures/pages-functions-app/index.d.ts b/fixtures/pages-functions-app/index.d.ts new file mode 100644 index 0000000..6d78d73 --- /dev/null +++ b/fixtures/pages-functions-app/index.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string; + export default content; +} diff --git a/fixtures/pages-functions-app/package.json b/fixtures/pages-functions-app/package.json new file mode 100644 index 0000000..06d87c8 --- /dev/null +++ b/fixtures/pages-functions-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fixture/pages-functions", + "private": true, + "sideEffects": false, + "main": "dist/worker.js", + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --binding=NAME=VALUE --binding=OTHER_NAME=THING=WITH=EQUALS --r2=bucket --port 8789", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "is-odd": "^3.0.1" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/pages-plugin": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-app/public/[id].js b/fixtures/pages-functions-app/public/[id].js new file mode 100644 index 0000000..d29038f --- /dev/null +++ b/fixtures/pages-functions-app/public/[id].js @@ -0,0 +1 @@ +// test script diff --git a/fixtures/pages-functions-app/public/_headers b/fixtures/pages-functions-app/public/_headers new file mode 100644 index 0000000..afd4be0 --- /dev/null +++ b/fixtures/pages-functions-app/public/_headers @@ -0,0 +1,6 @@ +/* + A-Header: Some-Value + +/header-test + ! A-Header + A-Header: New-Value \ No newline at end of file diff --git a/fixtures/pages-functions-app/public/_redirects b/fixtures/pages-functions-app/public/_redirects new file mode 100644 index 0000000..dedb379 --- /dev/null +++ b/fixtures/pages-functions-app/public/_redirects @@ -0,0 +1,2 @@ +/redirect /me +/users/:id /users/[id] 200 \ No newline at end of file diff --git a/fixtures/pages-functions-app/public/a.b.html b/fixtures/pages-functions-app/public/a.b.html new file mode 100644 index 0000000..bc3deb9 --- /dev/null +++ b/fixtures/pages-functions-app/public/a.b.html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <body> + <h1>Hello, a.b!</h1> + </body> +</html> diff --git a/fixtures/pages-functions-app/public/here.html b/fixtures/pages-functions-app/public/here.html new file mode 100644 index 0000000..ae9fd9c --- /dev/null +++ b/fixtures/pages-functions-app/public/here.html @@ -0,0 +1 @@ +<h1>Successful!</h1> diff --git a/fixtures/pages-functions-app/public/index.html b/fixtures/pages-functions-app/public/index.html new file mode 100644 index 0000000..1520bf7 --- /dev/null +++ b/fixtures/pages-functions-app/public/index.html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <body> + <h1>Hello, world!</h1> + </body> +</html> diff --git a/fixtures/pages-functions-app/public/some-asset.html b/fixtures/pages-functions-app/public/some-asset.html new file mode 100644 index 0000000..2d03483 --- /dev/null +++ b/fixtures/pages-functions-app/public/some-asset.html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <body> + <h1>An asset</h1> + </body> +</html> diff --git a/fixtures/pages-functions-app/public/users/[id].html b/fixtures/pages-functions-app/public/users/[id].html new file mode 100644 index 0000000..f846c03 --- /dev/null +++ b/fixtures/pages-functions-app/public/users/[id].html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <body> + <h1>Hello, /users/[id]!</h1> + </body> +</html> diff --git a/fixtures/pages-functions-app/static-assets/index.html b/fixtures/pages-functions-app/static-assets/index.html new file mode 100644 index 0000000..0956211 --- /dev/null +++ b/fixtures/pages-functions-app/static-assets/index.html @@ -0,0 +1,9 @@ +<!doctype html> +<html> + <head> + <title>Static asset + + +

Hello from an imported static asset!

+ + diff --git a/fixtures/pages-functions-app/tests/index.test.ts b/fixtures/pages-functions-app/tests/index.test.ts new file mode 100644 index 0000000..c722b3e --- /dev/null +++ b/fixtures/pages-functions-app/tests/index.test.ts @@ -0,0 +1,358 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions", () => { + let ip: string, + port: number, + stop: (() => Promise) | undefined, + getOutput: () => string; + + beforeAll(async () => { + ({ ip, port, stop, getOutput } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + [ + "--binding=NAME=VALUE", + "--binding=OTHER_NAME=THING=WITH=EQUALS", + "--r2=bucket", + "--port=0", + "--inspector-port=0", + ] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("renders static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + expect(response.headers.get("x-custom")).toBe("header value"); + const text = await response.text(); + expect(text).toContain("Hello, world!"); + }); + + it("renders pages with . characters", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/a.b`); + expect(response.headers.get("x-custom")).toBe("header value"); + const text = await response.text(); + expect(text).toContain("Hello, a.b!"); + }); + + it("parses URL encoded requests", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/[id].js`); + const text = await response.text(); + expect(text).toContain("// test script"); + }); + + it("parses URLs with regex chars", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/regex_chars/my-file`); + const text = await response.text(); + expect(text).toEqual("My file with regex chars"); + }); + + it("passes environment variables", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/variables`); + const env = await response.json(); + // Use objectContaining to allow for additional CF_PAGES_* variables + expect(env).toEqual( + expect.objectContaining({ + ASSETS: {}, + bucket: {}, + NAME: "VALUE", + OTHER_NAME: "THING=WITH=EQUALS", + VAR_1: "var #1 value", + VAR_3: "var #3 value", + VAR_MULTI_LINE_1: "A: line 1\nline 2", + VAR_MULTI_LINE_2: "B: line 1\nline 2", + EMPTY: "", + UNQUOTED: "unquoted value", // Note that whitespace is trimmed + }) + ); + }); + + it("intercepts static requests with next()", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/intercept`); + const text = await response.text(); + expect(text).toContain("Hello, world!"); + expect(response.headers.get("x-set-from-functions")).toBe("true"); + }); + + it("can make SSR responses", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/date`); + const text = await response.text(); + expect(text).toMatch(/\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/); + }); + + it("can use parameters", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/blog/hello-world`); + const text = await response.text(); + expect(text).toContain("

A blog with a slug: hello-world

"); + }); + + it("can override the incoming request with next() parameters", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/next`); + const text = await response.text(); + expect(text).toContain("

An asset

"); + }); + + describe("can mount a plugin", () => { + it("should mount Middleware", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/some-page` + ); + const text = await response.text(); + expect(text).toContain("
Set from a Plugin!
"); + }); + + it("should return a status code", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/status` + ); + const text = await response.text(); + expect(text).toMatchInlineSnapshot( + `"This should return a 502 status code"` + ); + expect(response.status).toBe(502); + }); + + it("should work with peer externals", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/mounted-plugin/ext`); + const text = await response.text(); + expect(text).toMatchInlineSnapshot(`"42 is even"`); + expect(response.status).toBe(200); + }); + + it("should mount a Plugin even if in a parameterized route", async ({ + expect, + }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-with-param/p123/plugin/status` + ); + const text = await response.text(); + expect(text).toMatchInlineSnapshot( + `"This should return a 502 status code"` + ); + expect(response.status).toBe(502); + }); + + it("should work for nested folders", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/api/v1/instance` + ); + const text = await response.text(); + expect(text).toMatchInlineSnapshot(`"Response from a nested folder"`); + }); + + it("should mount Fixed page", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/mounted-plugin/fixed`); + const text = await response.text(); + expect(text).toContain("I'm a fixed response"); + }); + + it("should support proxying through to next(request)", async ({ + expect, + }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/proxy-me-somewhere-else` + ); + const text = await response.text(); + expect(text).toContain("Successful!"); + }); + }); + + describe("can import static assets", () => { + it("should render a static asset", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/static`); + const text = await response.text(); + expect(text).toContain("

Hello from an imported static asset!

"); + }); + + it("should render from a Plugin", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/static` + ); + const text = await response.text(); + expect(text).toContain( + "

Hello from a static asset brought from a Plugin!

" + ); + }); + + it("should render static/foo", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/static/foo` + ); + const text = await response.text(); + expect(text).toContain("

foo

"); + }); + + it("should render static/dir/bar", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/mounted-plugin/static/dir/bar` + ); + const text = await response.text(); + expect(text).toContain("

bar

"); + }); + + it("supports importing .html from a function", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/import-html`); + expect(response.headers.get("x-custom")).toBe("header value"); + const text = await response.text(); + expect(text).toContain("

Hello from an imported static asset!

"); + }); + }); + + describe("it supports R2", () => { + it("should allow creates", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/r2/create`, { + method: "PUT", + }); + const object = (await response.json()) as { + key: string; + version: string; + }; + expect(object.key).toEqual("test"); + + const getResponse = await fetch(`http://${ip}:${port}/r2/get`); + const getObject = (await getResponse.json()) as { + key: string; + version: string; + }; + expect(getObject.key).toEqual("test"); + expect(getObject.version).toEqual(object.version); + }); + }); + + describe("redirects", () => { + it("still attaches redirects correctly", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/redirect`, { + redirect: "manual", + }); + expect(response.status).toEqual(302); + expect(response.headers.get("Location")).toEqual("/me"); + }); + + it("should support proxying (200) redirects", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/users/123`, { + redirect: "manual", + }); + const text = await response.text(); + expect(response.status).toEqual(200); + expect(text).toContain("Hello, /users/[id]!"); + }); + }); + + describe("headers", () => { + it("still attaches headers correctly", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + + expect(response.headers.get("A-Header")).toEqual("Some-Value"); + }); + + it("can unset and set together", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/header-test`); + + expect(response.headers.get("A-Header")).toEqual("New-Value"); + }); + }); + + describe("passThroughOnException", () => { + it("works on a single handler", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/passThroughOnExceptionOpen` + ); + + expect(response.status).toEqual(200); + expect(await response.text()).toContain("Hello, world!"); + }); + + it("defaults closed", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/passThroughOnExceptionClosed` + ); + + expect(response.status).toEqual(500); + expect(await response.text()).not.toContain("Hello, world!"); + }); + + it("works for nested handlers", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/passThroughOnException/nested` + ); + + expect(response.status).toEqual(200); + expect(await response.text()).toContain("Hello, world!"); + }); + + it("allows errors to still be manually caught in middleware", async ({ + expect, + }) => { + let response = await fetch( + `http://${ip}:${port}/passThroughOnExceptionWithCapture/nested` + ); + + expect(response.status).toEqual(200); + expect(await response.text()).toContain("Hello, world!"); + + response = await fetch( + `http://${ip}:${port}/passThroughOnExceptionWithCapture/nested?catch` + ); + + expect(response.status).toEqual(200); + expect(await response.text()).toMatchInlineSnapshot( + `"Manually caught error: ReferenceError: x is not defined"` + ); + }); + }); + + describe.concurrent("middleware data", () => { + it("allows middleware to set data", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/middleware-data/additional-data` + ); + + expect(response.status).toEqual(200); + const data = await response.json(); + expect(data).toEqual({ + foo: "bar", + }); + }); + + it("allows middleware to mutate data", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/middleware-data/mutate-data` + ); + + expect(response.status).toEqual(200); + const data = await response.json(); + expect(data).toEqual({ + foo: "bar", + bar: "baz", + }); + }); + + it("allows middleware to be overridden and not merged", async ({ + expect, + }) => { + const response = await fetch( + `http://${ip}:${port}/middleware-data/merge-data` + ); + const data = await response.json(); + expect(data).toEqual({ + bar: "baz", + }); + }); + + it("middleware throws when set to non-object", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/middleware-data/bad-data` + ); + + expect(response.status).toEqual(500); + }); + }); +}); diff --git a/fixtures/pages-functions-app/tests/tsconfig.json b/fixtures/pages-functions-app/tests/tsconfig.json new file mode 100644 index 0000000..f60c0bf --- /dev/null +++ b/fixtures/pages-functions-app/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-functions-app/tsconfig.json b/fixtures/pages-functions-app/tsconfig.json new file mode 100644 index 0000000..f71b527 --- /dev/null +++ b/fixtures/pages-functions-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "include": ["index.d.ts", "functions"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/fixtures/pages-functions-app/vitest.config.mts b/fixtures/pages-functions-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-functions-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-functions-unenv-alias/functions/[[path]].ts b/fixtures/pages-functions-unenv-alias/functions/[[path]].ts new file mode 100644 index 0000000..b39ea9a --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/functions/[[path]].ts @@ -0,0 +1,10 @@ +const fetch = require("cross-fetch"); + +export const onRequest = () => { + const supportsDefaultExports = typeof fetch === "function"; + const supportsNamedExports = typeof fetch.Headers === "function"; + + return new Response( + supportsDefaultExports && supportsNamedExports ? "OK!" : "KO!" + ); +}; diff --git a/fixtures/pages-functions-unenv-alias/package.json b/fixtures/pages-functions-unenv-alias/package.json new file mode 100644 index 0000000..0df7fdd --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-functions-unenv", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev:functions-app": "wrangler pages dev --port 8792", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-unenv-alias/tests/index.test.ts b/fixtures/pages-functions-unenv-alias/tests/index.test.ts new file mode 100644 index 0000000..ee22c01 --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/tests/index.test.ts @@ -0,0 +1,20 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { describe, it, onTestFinished } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages functions with unenv aliased packages", () => { + it("should run dev server when requiring an unenv aliased package", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./functions", + ["--port=0", "--inspector-port=0"] + ); + onTestFinished(stop); + const response = await fetch(`http://${ip}:${port}/`); + const body = await response.text(); + expect(body).toEqual(`OK!`); + }); +}); diff --git a/fixtures/pages-functions-unenv-alias/tests/tsconfig.json b/fixtures/pages-functions-unenv-alias/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-functions-unenv-alias/tsconfig.json b/fixtures/pages-functions-unenv-alias/tsconfig.json new file mode 100644 index 0000000..a729102 --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node", "@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true, + "checkJs": true + }, + "include": [ + "apps/workerjs-directory/_worker.js/index.js", + "apps/workerjs-file/_worker.js", + "functions/[[path]].ts", + "tests" + ] +} diff --git a/fixtures/pages-functions-unenv-alias/vitest.config.mts b/fixtures/pages-functions-unenv-alias/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-functions-unenv-alias/wrangler.jsonc b/fixtures/pages-functions-unenv-alias/wrangler.jsonc new file mode 100644 index 0000000..7bfd15a --- /dev/null +++ b/fixtures/pages-functions-unenv-alias/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "pages-functions-unenv-alias", + "compatibility_date": "2024-12-30", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/fixtures/pages-functions-wasm-app/README.md b/fixtures/pages-functions-wasm-app/README.md new file mode 100644 index 0000000..3598404 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/README.md @@ -0,0 +1,41 @@ +# ⚡️ pages-functions-wasm-app + +`pages-functions-wasm-app` is a test fixture that sets up a ⚡️Pages project with [Functions](https://developers.cloudflare.com/pages/platform/functions) and [`wasm` module imports](https://blog.cloudflare.com/workers-javascript-modules/) + +## Dev + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-wasm-app + +# start dev server +npm run dev +``` + +## Publish + +> Please note that in order to deploy this project to `.pages.dev` you need to have a [Cloudflare account](https://dash.cloudflare.com/login) + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-wasm-app + +# Deploy the directory of static assets as a Pages deployment +npm run publish +``` + +If deployment was successful, follow the URL refrenced in the success message in your terminal + +``` +✨ Deployment complete! Take a peek over at https:/..pages.dev +``` + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-wasm-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-functions-wasm-app/external-modules/add.wasm b/fixtures/pages-functions-wasm-app/external-modules/add.wasm new file mode 100644 index 0000000..357f72d Binary files /dev/null and b/fixtures/pages-functions-wasm-app/external-modules/add.wasm differ diff --git a/fixtures/pages-functions-wasm-app/external-modules/add.wat b/fixtures/pages-functions-wasm-app/external-modules/add.wat new file mode 100644 index 0000000..00c20e7 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/external-modules/add.wat @@ -0,0 +1,7 @@ +(module + (func $add (param $p1 i32) (param $p2 i32) (result i32) + local.get $p1 + local.get $p2 + i32.add) + (export "add" (func $add)) +) diff --git a/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.html b/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.html new file mode 100644 index 0000000..9ee3bcd --- /dev/null +++ b/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.html @@ -0,0 +1,5 @@ + + + [.html]: The meaning of life is 21 + + diff --git a/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.txt b/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.txt new file mode 100644 index 0000000..ee67156 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/external-modules/meaning-of-life.txt @@ -0,0 +1 @@ +[.txt]: The meaning of life is 21 \ No newline at end of file diff --git a/fixtures/pages-functions-wasm-app/functions/meaning-of-life-html.js b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-html.js new file mode 100644 index 0000000..08cdf63 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-html.js @@ -0,0 +1,7 @@ +import html from "./../external-modules/meaning-of-life.html"; + +export async function onRequest() { + return new Response(html, { + headers: { "Content-Type": "text/html" }, + }); +} diff --git a/fixtures/pages-functions-wasm-app/functions/meaning-of-life-text.js b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-text.js new file mode 100644 index 0000000..9d9d777 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-text.js @@ -0,0 +1,7 @@ +import text from "./../external-modules/meaning-of-life.txt"; + +export async function onRequest() { + return new Response(text, { + headers: { "Content-Type": "text/plain" }, + }); +} diff --git a/fixtures/pages-functions-wasm-app/functions/meaning-of-life-wasm.js b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-wasm.js new file mode 100644 index 0000000..9a32cc6 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/functions/meaning-of-life-wasm.js @@ -0,0 +1,8 @@ +import add from "../external-modules/add.wasm"; + +export async function onRequest() { + const addModule = await WebAssembly.instantiate(add); + return new Response( + `[.wasm]: The meaning of life is ${addModule.exports.add(20, 1)}` + ); +} diff --git a/fixtures/pages-functions-wasm-app/package.json b/fixtures/pages-functions-wasm-app/package.json new file mode 100644 index 0000000..5579118 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-functions-wasm", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8776", + "publish": "wrangler pages deploy public", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-wasm-app/public/index.html b/fixtures/pages-functions-wasm-app/public/index.html new file mode 100644 index 0000000..f57ce3d --- /dev/null +++ b/fixtures/pages-functions-wasm-app/public/index.html @@ -0,0 +1,6 @@ + + + +

Hello from pages-functions-wasm-app!

+ + diff --git a/fixtures/pages-functions-wasm-app/tests/index.test.ts b/fixtures/pages-functions-wasm-app/tests/index.test.ts new file mode 100644 index 0000000..a76ad1f --- /dev/null +++ b/fixtures/pages-functions-wasm-app/tests/index.test.ts @@ -0,0 +1,50 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions with wasm module imports", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should render static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}`); + const text = await response.text(); + expect(text).toContain("Hello from pages-functions-wasm-app!"); + }); + + it("should resolve wasm module imports and render /meaning-of-life-wasm", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/meaning-of-life-wasm`); + const text = await response.text(); + expect(text).toEqual("[.wasm]: The meaning of life is 21"); + }); + + it("should resolve text module imports and render /meaning-of-life-wasm-text", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/meaning-of-life-text`); + const text = await response.text(); + expect(text).toEqual("[.txt]: The meaning of life is 21"); + }); + + it("should resolve html module imports and render /meaning-of-life-wasm-html", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/meaning-of-life-html`); + const text = await response.text(); + expect(text).toContain(`[.html]: The meaning of life is 21`); + }); +}); diff --git a/fixtures/pages-functions-wasm-app/tests/tsconfig.json b/fixtures/pages-functions-wasm-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-functions-wasm-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-functions-wasm-app/tsconfig.json b/fixtures/pages-functions-wasm-app/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-functions-wasm-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-functions-wasm-app/vitest.config.mts b/fixtures/pages-functions-wasm-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-functions-wasm-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-functions-with-config-file-app/README.md b/fixtures/pages-functions-with-config-file-app/README.md new file mode 100644 index 0000000..baa8018 --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/README.md @@ -0,0 +1,29 @@ +# ⚡️ pages-functions-with-config-file-app + +`pages-functions-with-config-file-app` is a test fixture that sets up a ⚡️Pages⚡️ Functions project, with a `wrangler.toml` [configuration file](hhttps://developers.cloudflare.com/workers/wrangler/configuration). The purpose of this fixture is to demonstrate that `wrangler pages dev` can take configuration from a `wrangler.toml` file. + +## Local dev + +To test this fixture run `wrangler pages dev` in the fixture folder: + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-with-config-file-app + +# Start local dev server +npx wrangler pages dev +``` + +Once the local dev server was started, you should see the configuration specified in the `wrangler.toml` at the root of the fixture folder, affect the generated Worker. + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-with-config-file-app + +# Run tests +npm run test +``` + +You can still override what is in the wrangler.toml by adding command line args: wrangler pages dev --binding=KEY:VALUE diff --git a/fixtures/pages-functions-with-config-file-app/functions/celebrate.ts b/fixtures/pages-functions-with-config-file-app/functions/celebrate.ts new file mode 100644 index 0000000..1542fa6 --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/functions/celebrate.ts @@ -0,0 +1,11 @@ +export async function onRequest(context) { + return new Response( + `[/celebrate]:\n` + + `🎵 🎵 🎵\n` + + `You can turn this world around\n` + + `And bring back all of those happy days\n` + + `Put your troubles down\n` + + `It's time to ${context.env.VAR2}\n` + + `🎵 🎵 🎵` + ); +} diff --git a/fixtures/pages-functions-with-config-file-app/functions/holiday.ts b/fixtures/pages-functions-with-config-file-app/functions/holiday.ts new file mode 100644 index 0000000..a9e146b --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/functions/holiday.ts @@ -0,0 +1,11 @@ +export async function onRequest(context) { + return new Response( + `[/holiday]:\n` + + `🎵 🎵 🎵\n` + + `If we took a ${context.env.VAR1}\n` + + `Took some time to ${context.env.VAR2}\n` + + `Just one day out of life\n` + + `It would be, it would be so nice\n` + + `🎵 🎵 🎵` + ); +} diff --git a/fixtures/pages-functions-with-config-file-app/package.json b/fixtures/pages-functions-with-config-file-app/package.json new file mode 100644 index 0000000..464111e --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-functions-config", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-with-config-file-app/public/_routes.json b/fixtures/pages-functions-with-config-file-app/public/_routes.json new file mode 100644 index 0000000..2b71aa6 --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/celebrate"], + "exclude": ["/holiday"] +} diff --git a/fixtures/pages-functions-with-config-file-app/public/index.html b/fixtures/pages-functions-with-config-file-app/public/index.html new file mode 100644 index 0000000..f7b327e --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/public/index.html @@ -0,0 +1,6 @@ + + + +

Celebrate! Pages now supports 'wrangler.toml' 🎉

+ + diff --git a/fixtures/pages-functions-with-config-file-app/tests/index.test.ts b/fixtures/pages-functions-with-config-file-app/tests/index.test.ts new file mode 100644 index 0000000..327c4bb --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/tests/index.test.ts @@ -0,0 +1,48 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions with wrangler.toml", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + undefined, + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should render static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}`); + const text = await response.text(); + expect(text).toContain("Celebrate! Pages now supports 'wrangler.toml' 🎉"); + }); + + it("should correctly apply the routing rules provided in the custom _routes.json file", async ({ + expect, + }) => { + // matches `/celebrate` include rule + let response = await fetch(`http://${ip}:${port}/celebrate`); + let text = await response.text(); + expect(text).toEqual( + `[/celebrate]:\n` + + `🎵 🎵 🎵\n` + + `You can turn this world around\n` + + `And bring back all of those happy days\n` + + `Put your troubles down\n` + + `It's time to celebrate\n` + + `🎵 🎵 🎵` + ); + + // matches `/holiday` exclude rule + response = await fetch(`http://${ip}:${port}/holiday`); + text = await response.text(); + expect(text).toContain("Celebrate! Pages now supports 'wrangler.toml' 🎉"); + }); +}); diff --git a/fixtures/pages-functions-with-config-file-app/tests/tsconfig.json b/fixtures/pages-functions-with-config-file-app/tests/tsconfig.json new file mode 100644 index 0000000..f60c0bf --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-functions-with-config-file-app/tsconfig.json b/fixtures/pages-functions-with-config-file-app/tsconfig.json new file mode 100644 index 0000000..375ad3b --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["functions"] +} diff --git a/fixtures/pages-functions-with-config-file-app/vitest.config.mts b/fixtures/pages-functions-with-config-file-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-functions-with-config-file-app/wrangler.jsonc b/fixtures/pages-functions-with-config-file-app/wrangler.jsonc new file mode 100644 index 0000000..4a5b963 --- /dev/null +++ b/fixtures/pages-functions-with-config-file-app/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "pages_build_output_dir": "./public", + "name": "pages-functions-with-config-file-app", + "compatibility_date": "2024-01-01", + "vars": { + "VAR1": "holiday", + "VAR2": "celebrate", + }, +} diff --git a/fixtures/pages-functions-with-routes-app/CHANGELOG.md b/fixtures/pages-functions-with-routes-app/CHANGELOG.md new file mode 100644 index 0000000..d1b5a88 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/CHANGELOG.md @@ -0,0 +1,18 @@ +# pages-functions-with-routes-app + +## 0.0.1 + +### Patch Changes + +- [#2065](https://github.com/cloudflare/workers-sdk/pull/2065) [`14c44588`](https://github.com/cloudflare/workers-sdk/commit/14c44588c9d22e9c9f2ad2740df57809d0cbcfbc) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - fix(pages): `wrangler pages dev` matches routing rules in `_routes.json` too loosely + + Currently, the logic by which we transform routing rules in `_routes.json` to + regular expressions, so we can perform `pathname` matching & routing when we + run `wrangler pages dev`, is too permissive, and leads to serving incorrect + assets for certain url paths. + + For example, a routing rule such as `/foo` will incorrectly match pathname + `/bar/foo`. Similarly, pathname `/foo` will be incorrectly matched by the + `/` routing rule. + This commit fixes our routing rule to pathname matching logic and brings + `wrangler pages dev` on par with routing in deployed Pages projects. diff --git a/fixtures/pages-functions-with-routes-app/README.md b/fixtures/pages-functions-with-routes-app/README.md new file mode 100644 index 0000000..c15778e --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/README.md @@ -0,0 +1,31 @@ +# ⚡️ pages-functions-with-routes-app + +`pages-functions-with-routes-app` is a test fixture that sets up a ⚡️Pages project with [Functions](https://developers.cloudflare.com/pages/platform/functions) and a custom `_routes.json` file + +## Publish + +> Please note that in order to deploy this project to `.pages.dev` you need to have a [Cloudflare account](https://dash.cloudflare.com/login) + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-with-routes-app + +# Deploy the directory of static assets as a Pages deployment +npx wrangler pages deploy public +``` + +If deployment was successful, follow the URL refrenced in the success message in your terminal + +``` +✨ Deployment complete! Take a peek over at https:/..pages.dev +``` + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-functions-with-routes-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-functions-with-routes-app/functions/bye.ts b/fixtures/pages-functions-with-routes-app/functions/bye.ts new file mode 100644 index 0000000..7b0ffeb --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/bye.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("[/functions/bye]: Adieu 🙁"); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/date.ts b/fixtures/pages-functions-with-routes-app/functions/date.ts new file mode 100644 index 0000000..c680a55 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/date.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response(`[/functions/date]: ${new Date().toISOString()}`); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/greeting/bye.ts b/fixtures/pages-functions-with-routes-app/functions/greeting/bye.ts new file mode 100644 index 0000000..ae9d368 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/greeting/bye.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("[/functions/greeting/bye]: A plus tard alligator 👋"); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/greeting/hello.ts b/fixtures/pages-functions-with-routes-app/functions/greeting/hello.ts new file mode 100644 index 0000000..d47bc04 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/greeting/hello.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("[/functions/greeting/hello]: Bonjour le monde!"); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/greeting/index.ts b/fixtures/pages-functions-with-routes-app/functions/greeting/index.ts new file mode 100644 index 0000000..e3c1489 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/greeting/index.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("[/functions/greeting/index]: Bonjour alligator!"); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/greetings.ts b/fixtures/pages-functions-with-routes-app/functions/greetings.ts new file mode 100644 index 0000000..ab53815 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/greetings.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("[/functions/greetings]: Bonjour à tous!"); +} diff --git a/fixtures/pages-functions-with-routes-app/functions/index.ts b/fixtures/pages-functions-with-routes-app/functions/index.ts new file mode 100644 index 0000000..f3afeda --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/functions/index.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("ROOT"); +} diff --git a/fixtures/pages-functions-with-routes-app/package.json b/fixtures/pages-functions-with-routes-app/package.json new file mode 100644 index 0000000..a0fb0cc --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-functions-routes", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8776", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-with-routes-app/public/_routes.json b/fixtures/pages-functions-with-routes-app/public/_routes.json new file mode 100644 index 0000000..255dd80 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/", "/greeting/*", "/greeting*", "/date"], + "exclude": ["/bye*", "/date", "/*.*"] +} diff --git a/fixtures/pages-functions-with-routes-app/public/greeting/test.json b/fixtures/pages-functions-with-routes-app/public/greeting/test.json new file mode 100644 index 0000000..30164b1 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/public/greeting/test.json @@ -0,0 +1,3 @@ +{ + "value": 99 +} diff --git a/fixtures/pages-functions-with-routes-app/public/index.html b/fixtures/pages-functions-with-routes-app/public/index.html new file mode 100644 index 0000000..6d1288b --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/public/index.html @@ -0,0 +1,8 @@ + + + +

+ Bienvenue sur notre projet ✨ pages-functions-with-routes-app! +

+ + diff --git a/fixtures/pages-functions-with-routes-app/tests/index.test.ts b/fixtures/pages-functions-with-routes-app/tests/index.test.ts new file mode 100644 index 0000000..0324f67 --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/tests/index.test.ts @@ -0,0 +1,76 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions with custom _routes.json", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should render static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/undefined-route`); + const text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-functions-with-routes-app!" + ); + }); + + it("should correctly apply the routing rules provided in the custom _routes.json file", async ({ + expect, + }) => { + // matches / include rule + let response = await fetch(`http://${ip}:${port}`); + let text = await response.text(); + expect(text).toEqual("ROOT"); + + // matches /greeting/* include rule + response = await fetch(`http://${ip}:${port}/greeting`); + text = await response.text(); + expect(text).toEqual("[/functions/greeting/index]: Bonjour alligator!"); + + // matches /greeting/* include rule + response = await fetch(`http://${ip}:${port}/greeting/hello`); + text = await response.text(); + expect(text).toEqual("[/functions/greeting/hello]: Bonjour le monde!"); + + // matches /greeting/* include rule + response = await fetch(`http://${ip}:${port}/greeting/bye`); + text = await response.text(); + expect(text).toEqual("[/functions/greeting/bye]: A plus tard alligator 👋"); + + // matches both include|exclude /date rules, but exclude has priority + response = await fetch(`http://${ip}:${port}/date`); + text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-functions-with-routes-app!" + ); + + // matches /bye* exclude rule + response = await fetch(`http://${ip}:${port}/bye`); + text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-functions-with-routes-app!" + ); + + // matches /greeting* include rule + response = await fetch(`http://${ip}:${port}/greetings`); + text = await response.text(); + expect(text).toEqual("[/functions/greetings]: Bonjour à tous!"); + + // matches /*.* exclude rule + response = await fetch(`http://${ip}:${port}/greeting/test.json`); + const json = await response.json(); + expect(json).toEqual({ value: 99 }); + }); +}); diff --git a/fixtures/pages-functions-with-routes-app/tests/tsconfig.json b/fixtures/pages-functions-with-routes-app/tests/tsconfig.json new file mode 100644 index 0000000..f60c0bf --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-functions-with-routes-app/tsconfig.json b/fixtures/pages-functions-with-routes-app/tsconfig.json new file mode 100644 index 0000000..375ad3b --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["functions"] +} diff --git a/fixtures/pages-functions-with-routes-app/vitest.config.mts b/fixtures/pages-functions-with-routes-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-functions-with-routes-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-nodejs-v2-compat/apps/functions/README.md b/fixtures/pages-nodejs-v2-compat/apps/functions/README.md new file mode 100644 index 0000000..ac0815e --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/apps/functions/README.md @@ -0,0 +1,5 @@ +# Page Functions fixture + +This directory is just here to avoid picking up any other \_worker.js files in the other app. +The actual Pages Functions handlers are in the `functions` directory at the root of the fixture. +They have to be there because Pages likes `wrangler pages dev` to be run from the root of the fixture and it always looks for a `functions` directory in that root. diff --git a/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/README.md b/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/README.md new file mode 100644 index 0000000..a3348ed --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/README.md @@ -0,0 +1 @@ +# \_worker.js directory fixture diff --git a/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/_worker.js/index.js b/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/_worker.js/index.js new file mode 100644 index 0000000..4dea348 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/apps/workerjs-directory/_worker.js/index.js @@ -0,0 +1,12 @@ +process.env.foo = "bar"; + +// Check that we don't get a build time error when assigning to globalThis.process. +globalThis.process = process; + +export default { + fetch() { + return new Response( + `_worker.js directory, process: ${Object.keys(process).sort()}` + ); + }, +}; diff --git a/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/README.md b/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/README.md new file mode 100644 index 0000000..f38aec3 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/README.md @@ -0,0 +1 @@ +# \_worker.js file fixture diff --git a/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/_worker.js b/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/_worker.js new file mode 100644 index 0000000..370cef8 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/apps/workerjs-file/_worker.js @@ -0,0 +1,12 @@ +process.env.foo = "bar"; + +// Check that we don't get a build time error when assigning to globalThis.process. +globalThis.process = process; + +export default { + fetch() { + return new Response( + `_worker.js file, process: ${Object.keys(process).sort()}` + ); + }, +}; diff --git a/fixtures/pages-nodejs-v2-compat/functions/[[path]].ts b/fixtures/pages-nodejs-v2-compat/functions/[[path]].ts new file mode 100644 index 0000000..a6f6c28 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/functions/[[path]].ts @@ -0,0 +1,10 @@ +process.env.foo = "bar"; + +// Check that we don't get a build time error when assigning to globalThis.process. +globalThis.process = process; + +export const onRequest = () => { + return new Response( + `Pages functions, process: ${Object.keys(process).sort()}` + ); +}; diff --git a/fixtures/pages-nodejs-v2-compat/package.json b/fixtures/pages-nodejs-v2-compat/package.json new file mode 100644 index 0000000..08c4474 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/package.json @@ -0,0 +1,22 @@ +{ + "name": "@fixture/pages-nodejs-v2", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev:functions-app": "wrangler pages dev ./apps/functions --port 8792", + "dev:workerjs-directory": "wrangler pages dev ./apps/workerjs-directory --port 8792", + "dev:workerjs-file": "wrangler pages dev ./apps/workerjs-file --port 8792", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-nodejs-v2-compat/tests/index.test.ts b/fixtures/pages-nodejs-v2-compat/tests/index.test.ts new file mode 100644 index 0000000..43e8829 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/tests/index.test.ts @@ -0,0 +1,54 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { describe, it, onTestFinished } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages with Node.js compat v2", () => { + describe("with _worker.js file", () => { + it("should polyfill `process`", async ({ expect }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./apps/workerjs-file", + ["--port=0", "--inspector-port=0"] + ); + onTestFinished(stop); + const response = await fetch(`http://${ip}:${port}/`); + const body = await response.text(); + expect(body).toMatchInlineSnapshot( + `"_worker.js file, process: _channel,_debugEnd,_debugProcess,_disconnect,_events,_eventsCount,_exiting,_fatalException,_getActiveHandles,_getActiveRequests,_handleQueue,_kill,_linkedBinding,_maxListeners,_pendingMessage,_preload_modules,_rawDebug,_send,_startProfilerIdleNotifier,_stopProfilerIdleNotifier,_tickCallback,abort,addListener,allowedNodeEnvironmentFlags,arch,argv,argv0,assert,availableMemory,binding,channel,chdir,config,connected,constrainedMemory,cpuUsage,cwd,debugPort,disconnect,dlopen,domain,emit,emitWarning,env,eventNames,execArgv,execPath,exit,exitCode,features,finalization,getActiveResourcesInfo,getBuiltinModule,getMaxListeners,getegid,geteuid,getgid,getgroups,getuid,hasUncaughtExceptionCaptureCallback,hrtime,initgroups,kill,listenerCount,listeners,loadEnvFile,mainModule,memoryUsage,moduleLoadList,nextTick,off,on,once,openStdin,permission,pid,platform,ppid,prependListener,prependOnceListener,rawListeners,reallyExit,release,removeAllListeners,removeListener,report,resourceUsage,send,setMaxListeners,setSourceMapsEnabled,setUncaughtExceptionCaptureCallback,setegid,seteuid,setgid,setgroups,setuid,sourceMapsEnabled,stderr,stdin,stdout,throwDeprecation,title,traceDeprecation,umask,uptime,version,versions"` + ); + }); + }); + + describe("with _worker.js directory", () => { + it("should polyfill `process`", async ({ expect }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./apps/workerjs-directory", + ["--port=0", "--inspector-port=0"] + ); + onTestFinished(stop); + const response = await fetch(`http://${ip}:${port}/`); + const body = await response.text(); + expect(body).toMatchInlineSnapshot( + `"_worker.js directory, process: _channel,_debugEnd,_debugProcess,_disconnect,_events,_eventsCount,_exiting,_fatalException,_getActiveHandles,_getActiveRequests,_handleQueue,_kill,_linkedBinding,_maxListeners,_pendingMessage,_preload_modules,_rawDebug,_send,_startProfilerIdleNotifier,_stopProfilerIdleNotifier,_tickCallback,abort,addListener,allowedNodeEnvironmentFlags,arch,argv,argv0,assert,availableMemory,binding,channel,chdir,config,connected,constrainedMemory,cpuUsage,cwd,debugPort,disconnect,dlopen,domain,emit,emitWarning,env,eventNames,execArgv,execPath,exit,exitCode,features,finalization,getActiveResourcesInfo,getBuiltinModule,getMaxListeners,getegid,geteuid,getgid,getgroups,getuid,hasUncaughtExceptionCaptureCallback,hrtime,initgroups,kill,listenerCount,listeners,loadEnvFile,mainModule,memoryUsage,moduleLoadList,nextTick,off,on,once,openStdin,permission,pid,platform,ppid,prependListener,prependOnceListener,rawListeners,reallyExit,release,removeAllListeners,removeListener,report,resourceUsage,send,setMaxListeners,setSourceMapsEnabled,setUncaughtExceptionCaptureCallback,setegid,seteuid,setgid,setgroups,setuid,sourceMapsEnabled,stderr,stdin,stdout,throwDeprecation,title,traceDeprecation,umask,uptime,version,versions"` + ); + }); + }); + + describe("with Pages functions", () => { + it("should polyfill `process`", async ({ expect }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./apps/functions", + ["--port=0", "--inspector-port=0"] + ); + onTestFinished(stop); + const response = await fetch(`http://${ip}:${port}/`); + const body = await response.text(); + expect(body).toMatchInlineSnapshot( + `"Pages functions, process: _channel,_debugEnd,_debugProcess,_disconnect,_events,_eventsCount,_exiting,_fatalException,_getActiveHandles,_getActiveRequests,_handleQueue,_kill,_linkedBinding,_maxListeners,_pendingMessage,_preload_modules,_rawDebug,_send,_startProfilerIdleNotifier,_stopProfilerIdleNotifier,_tickCallback,abort,addListener,allowedNodeEnvironmentFlags,arch,argv,argv0,assert,availableMemory,binding,channel,chdir,config,connected,constrainedMemory,cpuUsage,cwd,debugPort,disconnect,dlopen,domain,emit,emitWarning,env,eventNames,execArgv,execPath,exit,exitCode,features,finalization,getActiveResourcesInfo,getBuiltinModule,getMaxListeners,getegid,geteuid,getgid,getgroups,getuid,hasUncaughtExceptionCaptureCallback,hrtime,initgroups,kill,listenerCount,listeners,loadEnvFile,mainModule,memoryUsage,moduleLoadList,nextTick,off,on,once,openStdin,permission,pid,platform,ppid,prependListener,prependOnceListener,rawListeners,reallyExit,release,removeAllListeners,removeListener,report,resourceUsage,send,setMaxListeners,setSourceMapsEnabled,setUncaughtExceptionCaptureCallback,setegid,seteuid,setgid,setgroups,setuid,sourceMapsEnabled,stderr,stdin,stdout,throwDeprecation,title,traceDeprecation,umask,uptime,version,versions"` + ); + }); + }); +}); diff --git a/fixtures/pages-nodejs-v2-compat/tests/tsconfig.json b/fixtures/pages-nodejs-v2-compat/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-nodejs-v2-compat/tsconfig.json b/fixtures/pages-nodejs-v2-compat/tsconfig.json new file mode 100644 index 0000000..a729102 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node", "@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true, + "checkJs": true + }, + "include": [ + "apps/workerjs-directory/_worker.js/index.js", + "apps/workerjs-file/_worker.js", + "functions/[[path]].ts", + "tests" + ] +} diff --git a/fixtures/pages-nodejs-v2-compat/vitest.config.mts b/fixtures/pages-nodejs-v2-compat/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-nodejs-v2-compat/wrangler.jsonc b/fixtures/pages-nodejs-v2-compat/wrangler.jsonc new file mode 100644 index 0000000..f14c814 --- /dev/null +++ b/fixtures/pages-nodejs-v2-compat/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "pages-nodejs-compat", + "compatibility_date": "2024-08-20", + "compatibility_flags": ["nodejs_compat_v2"], +} diff --git a/fixtures/pages-plugin-example/.gitignore b/fixtures/pages-plugin-example/.gitignore new file mode 100644 index 0000000..f720180 --- /dev/null +++ b/fixtures/pages-plugin-example/.gitignore @@ -0,0 +1 @@ +/index.js \ No newline at end of file diff --git a/fixtures/pages-plugin-example/functions/_middleware.ts b/fixtures/pages-plugin-example/functions/_middleware.ts new file mode 100644 index 0000000..74d0cba --- /dev/null +++ b/fixtures/pages-plugin-example/functions/_middleware.ts @@ -0,0 +1,32 @@ +import type { PluginArgs } from ".."; + +type ExamplePagesPluginFunction< + Env = unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Params extends string = any, + Data extends Record = Record, +> = PagesPluginFunction; + +class BodyHandler { + footerText: string; + + constructor({ footerText }) { + this.footerText = footerText; + } + + element(element) { + // Don't actually set HTML like this! + element.append(`
${this.footerText}
`, { html: true }); + } +} + +export const onRequest: ExamplePagesPluginFunction = async ({ + next, + pluginArgs, +}) => { + const response = await next(); + + return new HTMLRewriter() + .on("body", new BodyHandler({ footerText: pluginArgs.footerText })) + .transform(response); +}; diff --git a/fixtures/pages-plugin-example/functions/api/v1/instance.ts b/fixtures/pages-plugin-example/functions/api/v1/instance.ts new file mode 100644 index 0000000..53ff35a --- /dev/null +++ b/fixtures/pages-plugin-example/functions/api/v1/instance.ts @@ -0,0 +1 @@ +export const onRequest = () => new Response("Response from a nested folder"); diff --git a/fixtures/pages-plugin-example/functions/ext.ts b/fixtures/pages-plugin-example/functions/ext.ts new file mode 100644 index 0000000..550f2ad --- /dev/null +++ b/fixtures/pages-plugin-example/functions/ext.ts @@ -0,0 +1,5 @@ +import isOdd from "is-odd"; + +export const onRequest: PagesFunction = () => { + return new Response(`42 is ${isOdd(42) ? "odd" : "even"}`); +}; diff --git a/fixtures/pages-plugin-example/functions/fixed.ts b/fixtures/pages-plugin-example/functions/fixed.ts new file mode 100644 index 0000000..59fb7a0 --- /dev/null +++ b/fixtures/pages-plugin-example/functions/fixed.ts @@ -0,0 +1,3 @@ +export const onRequest: PagesFunction = () => { + return new Response("I'm a fixed response"); +}; diff --git a/fixtures/pages-plugin-example/functions/proxy-me-somewhere-else.ts b/fixtures/pages-plugin-example/functions/proxy-me-somewhere-else.ts new file mode 100644 index 0000000..b999910 --- /dev/null +++ b/fixtures/pages-plugin-example/functions/proxy-me-somewhere-else.ts @@ -0,0 +1 @@ +export const onRequest = ({ next }) => next("/here"); diff --git a/fixtures/pages-plugin-example/functions/static/_middleware.ts b/fixtures/pages-plugin-example/functions/static/_middleware.ts new file mode 100644 index 0000000..63d4986 --- /dev/null +++ b/fixtures/pages-plugin-example/functions/static/_middleware.ts @@ -0,0 +1 @@ +export { onRequest } from "assets:../../public"; diff --git a/fixtures/pages-plugin-example/functions/status.ts b/fixtures/pages-plugin-example/functions/status.ts new file mode 100644 index 0000000..bb64261 --- /dev/null +++ b/fixtures/pages-plugin-example/functions/status.ts @@ -0,0 +1,3 @@ +export const onRequest = () => { + return new Response("This should return a 502 status code", { status: 502 }); +}; diff --git a/fixtures/pages-plugin-example/index.d.ts b/fixtures/pages-plugin-example/index.d.ts new file mode 100644 index 0000000..a357a2d --- /dev/null +++ b/fixtures/pages-plugin-example/index.d.ts @@ -0,0 +1,5 @@ +export type PluginArgs = { + footerText: string; +}; + +export default function (args: PluginArgs): PagesFunction; diff --git a/fixtures/pages-plugin-example/package.json b/fixtures/pages-plugin-example/package.json new file mode 100644 index 0000000..57c78a9 --- /dev/null +++ b/fixtures/pages-plugin-example/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/pages-plugin", + "private": true, + "files": [ + "dist/", + "index.d.ts", + "tsconfig.json", + "public/" + ], + "main": "dist/index.js", + "types": "index.d.ts", + "scripts": { + "build": "wrangler pages functions build --plugin --outdir=dist --external=is-odd", + "check:type": "tsc" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "is-odd": "^3.0.1", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-plugin-example/public/dir/bar.html b/fixtures/pages-plugin-example/public/dir/bar.html new file mode 100644 index 0000000..792ce70 --- /dev/null +++ b/fixtures/pages-plugin-example/public/dir/bar.html @@ -0,0 +1,9 @@ + + + + Static asset from a Plugin! + + +

bar

+ + diff --git a/fixtures/pages-plugin-example/public/foo.html b/fixtures/pages-plugin-example/public/foo.html new file mode 100644 index 0000000..1beda88 --- /dev/null +++ b/fixtures/pages-plugin-example/public/foo.html @@ -0,0 +1,9 @@ + + + + Static asset from a Plugin! + + +

foo

+ + diff --git a/fixtures/pages-plugin-example/public/index.html b/fixtures/pages-plugin-example/public/index.html new file mode 100644 index 0000000..832789b --- /dev/null +++ b/fixtures/pages-plugin-example/public/index.html @@ -0,0 +1,9 @@ + + + + Static asset from a Plugin! + + +

Hello from a static asset brought from a Plugin!

+ + diff --git a/fixtures/pages-plugin-example/tsconfig.json b/fixtures/pages-plugin-example/tsconfig.json new file mode 100644 index 0000000..b8cb913 --- /dev/null +++ b/fixtures/pages-plugin-example/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["functions", "index.d.ts"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + } +} diff --git a/fixtures/pages-plugin-example/turbo.json b/fixtures/pages-plugin-example/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/pages-plugin-example/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/pages-plugin-mounted-on-root-app/.gitignore b/fixtures/pages-plugin-mounted-on-root-app/.gitignore new file mode 100644 index 0000000..79451db --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/.gitignore @@ -0,0 +1 @@ +cdn-cgi/ diff --git a/fixtures/pages-plugin-mounted-on-root-app/functions/_middleware.ts b/fixtures/pages-plugin-mounted-on-root-app/functions/_middleware.ts new file mode 100644 index 0000000..df50a1f --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/functions/_middleware.ts @@ -0,0 +1,3 @@ +import examplePlugin from "@fixture/pages-plugin"; + +export const onRequest = examplePlugin({ footerText: "Set from a Plugin!" }); diff --git a/fixtures/pages-plugin-mounted-on-root-app/index.d.ts b/fixtures/pages-plugin-mounted-on-root-app/index.d.ts new file mode 100644 index 0000000..6d78d73 --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/index.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string; + export default content; +} diff --git a/fixtures/pages-plugin-mounted-on-root-app/package.json b/fixtures/pages-plugin-mounted-on-root-app/package.json new file mode 100644 index 0000000..01c9385 --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fixture/pages-plugin-root", + "private": true, + "sideEffects": false, + "main": "dist/worker.js", + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8793", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "is-odd": "^3.0.1" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/pages-plugin": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-plugin-mounted-on-root-app/public/index.html b/fixtures/pages-plugin-mounted-on-root-app/public/index.html new file mode 100644 index 0000000..1520bf7 --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/public/index.html @@ -0,0 +1,6 @@ + + + +

Hello, world!

+ + diff --git a/fixtures/pages-plugin-mounted-on-root-app/tests/index.test.ts b/fixtures/pages-plugin-mounted-on-root-app/tests/index.test.ts new file mode 100644 index 0000000..4d56d3c --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/tests/index.test.ts @@ -0,0 +1,28 @@ +import * as path from "path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions", () => { + let ip: string; + let port: number; + let stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + path.resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("mounts a plugin correctly at root", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/api/v1/instance`); + const text = await response.text(); + expect(text).toMatchInlineSnapshot(`"Response from a nested folder"`); + }); +}); diff --git a/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json b/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json new file mode 100644 index 0000000..f60c0bf --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-plugin-mounted-on-root-app/tsconfig.json b/fixtures/pages-plugin-mounted-on-root-app/tsconfig.json new file mode 100644 index 0000000..b7ef12c --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["index.d.ts", "functions"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + } +} diff --git a/fixtures/pages-plugin-mounted-on-root-app/vitest.config.mts b/fixtures/pages-plugin-mounted-on-root-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-plugin-mounted-on-root-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-proxy-app/package.json b/fixtures/pages-proxy-app/package.json new file mode 100644 index 0000000..6cb2fbe --- /dev/null +++ b/fixtures/pages-proxy-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "@fixture/pages-proxy", + "private": true, + "sideEffects": false, + "main": "server/index.js", + "scripts": { + "build": "esbuild --bundle --platform=node server/index.ts --outfile=dist/index.js", + "check:type": "tsc", + "dev": "wrangler pages dev --compatibility-date=2024-01-17 --port 8790 --proxy 8791 -- pnpm run server", + "server": "node dist/index.js", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "miniflare": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-proxy-app/server/index.ts b/fixtures/pages-proxy-app/server/index.ts new file mode 100644 index 0000000..e6efbc2 --- /dev/null +++ b/fixtures/pages-proxy-app/server/index.ts @@ -0,0 +1,10 @@ +import { createServer } from "http"; + +const server = createServer(); + +server.on("request", (req, res) => { + res.write("Host:" + req.headers.host); + res.end(); +}); + +server.listen(8791); diff --git a/fixtures/pages-proxy-app/tests/index.test.ts b/fixtures/pages-proxy-app/tests/index.test.ts new file mode 100644 index 0000000..8333c75 --- /dev/null +++ b/fixtures/pages-proxy-app/tests/index.test.ts @@ -0,0 +1,34 @@ +import { fork } from "node:child_process"; +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; +import type { ChildProcess } from "node:child_process"; + +describe("pages-proxy-app", async () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + let devServer: ChildProcess; + + beforeAll(async () => { + devServer = fork(resolve(__dirname, "../dist/index.js"), { + stdio: "ignore", + }); + + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + undefined, + ["--port=0", "--inspector-port=0", "--proxy=8791"] + )); + }); + + afterAll(async () => { + await stop?.(); + devServer.kill(); + }); + + it("receives the correct Host header", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain(`Host:${ip}:${port}`); + }); +}); diff --git a/fixtures/pages-proxy-app/tests/tsconfig.json b/fixtures/pages-proxy-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-proxy-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-proxy-app/tsconfig.json b/fixtures/pages-proxy-app/tsconfig.json new file mode 100644 index 0000000..6d77a1e --- /dev/null +++ b/fixtures/pages-proxy-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "include": ["server"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/fixtures/pages-proxy-app/turbo.json b/fixtures/pages-proxy-app/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/pages-proxy-app/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/pages-proxy-app/vitest.config.mts b/fixtures/pages-proxy-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-proxy-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-redirected-config/.gitignore b/fixtures/pages-redirected-config/.gitignore new file mode 100644 index 0000000..2cf1da4 --- /dev/null +++ b/fixtures/pages-redirected-config/.gitignore @@ -0,0 +1,2 @@ +dist +build \ No newline at end of file diff --git a/fixtures/pages-redirected-config/package.json b/fixtures/pages-redirected-config/package.json new file mode 100644 index 0000000..aace93d --- /dev/null +++ b/fixtures/pages-redirected-config/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/config-redirected-pages", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "build": "node -r esbuild-register tools/build.ts", + "check:type": "tsc", + "dev": "pnpm run build && wrangler pages dev", + "test:ci": "pnpm run build && vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-redirected-config/src/index.js b/fixtures/pages-redirected-config/src/index.js new file mode 100644 index 0000000..e1ce8e6 --- /dev/null +++ b/fixtures/pages-redirected-config/src/index.js @@ -0,0 +1,5 @@ +export default { + async fetch(request, env) { + return new Response("Generated: " + env.generated ?? false); + }, +}; diff --git a/fixtures/pages-redirected-config/tests/index.test.ts b/fixtures/pages-redirected-config/tests/index.test.ts new file mode 100644 index 0000000..b3849ed --- /dev/null +++ b/fixtures/pages-redirected-config/tests/index.test.ts @@ -0,0 +1,52 @@ +import { rmSync, writeFileSync } from "fs"; +import path, { resolve } from "path"; +import { fetch } from "undici"; +import { describe, it, onTestFinished } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +const basePath = resolve(__dirname, ".."); + +describe("wrangler pages dev", () => { + it("uses the generated config if there is no wrangler.toml", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerPagesDev(basePath, undefined, [ + "--port=0", + "--inspector-port=0", + ]); + onTestFinished(async () => await stop?.()); + + // Note that the local protocol defaults to http + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(`"Generated: true"`); + }); + + it("uses the generated config instead of a user wrangler.toml", async ({ + expect, + }) => { + writeFileSync( + path.join(basePath, "wrangler.toml"), + [ + `name = "redirected-config-worker"`, + `compatibility_date = "2024-12-01"`, + `pages_build_output_dir = "public"`, + ].join("\n") + ); + const { ip, port, stop } = await runWranglerPagesDev(basePath, undefined, [ + "--port=0", + "--inspector-port=0", + ]); + onTestFinished(async () => { + rmSync(path.join(basePath, "wrangler.toml"), { force: true }); + await stop?.(); + }); + + // Note that the local protocol defaults to http + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(`"Generated: true"`); + }); +}); diff --git a/fixtures/pages-redirected-config/tools/build.ts b/fixtures/pages-redirected-config/tools/build.ts new file mode 100644 index 0000000..01594c4 --- /dev/null +++ b/fixtures/pages-redirected-config/tools/build.ts @@ -0,0 +1,24 @@ +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "fs"; + +// Create a pseudo build directory +rmSync("build", { recursive: true, force: true }); +mkdirSync("build"); +const config = { + name: "redirected-config-worker", + compatibility_date: "2024-12-01", + pages_build_output_dir: "./public", + vars: { generated: true }, +}; +writeFileSync("build/wrangler.json", JSON.stringify(config, undefined, 2)); + +mkdirSync("build/public"); +copyFileSync("src/index.js", "build/public/_worker.js"); + +// Create the redirect file +rmSync(".wrangler/deploy", { recursive: true, force: true }); +mkdirSync(".wrangler/deploy", { recursive: true }); +const redirect = { configPath: "../../build/wrangler.json" }; +writeFileSync( + ".wrangler/deploy/config.json", + JSON.stringify(redirect, undefined, 2) +); diff --git a/fixtures/pages-redirected-config/tsconfig.json b/fixtures/pages-redirected-config/tsconfig.json new file mode 100644 index 0000000..7480e11 --- /dev/null +++ b/fixtures/pages-redirected-config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-redirected-config/turbo.json b/fixtures/pages-redirected-config/turbo.json new file mode 100644 index 0000000..3394ff5 --- /dev/null +++ b/fixtures/pages-redirected-config/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["build/**"] + } + } +} diff --git a/fixtures/pages-redirected-config/vitest.config.mts b/fixtures/pages-redirected-config/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-redirected-config/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-simple-assets/index.d.ts b/fixtures/pages-simple-assets/index.d.ts new file mode 100644 index 0000000..6d78d73 --- /dev/null +++ b/fixtures/pages-simple-assets/index.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string; + export default content; +} diff --git a/fixtures/pages-simple-assets/package.json b/fixtures/pages-simple-assets/package.json new file mode 100644 index 0000000..4638795 --- /dev/null +++ b/fixtures/pages-simple-assets/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/pages-simple-assets", + "private": true, + "sideEffects": false, + "main": "dist/worker.js", + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-simple-assets/public/index.html b/fixtures/pages-simple-assets/public/index.html new file mode 100644 index 0000000..9f735a6 --- /dev/null +++ b/fixtures/pages-simple-assets/public/index.html @@ -0,0 +1 @@ +

Hello, world!

diff --git a/fixtures/pages-simple-assets/tests/index.test.ts b/fixtures/pages-simple-assets/tests/index.test.ts new file mode 100644 index 0000000..6fb327b --- /dev/null +++ b/fixtures/pages-simple-assets/tests/index.test.ts @@ -0,0 +1,65 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Functions", async () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("renders static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain("Hello, world!"); + }); + + it("doesn't escape out of the build output directory", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/..%2fpackage.json`); + let text = await response.text(); + expect(text).toContain("Hello, world!"); + + response = await fetch( + `http://${ip}:${port}/other-path%2f..%2f..%2fpackage.json` + ); + text = await response.text(); + expect(text).toContain("Hello, world!"); + }); + + it("doesn't redirect to protocol-less URLs", async ({ expect }) => { + { + const response = await fetch( + `http://${ip}:${port}/%2Fwww.example.com/index/`, + { redirect: "manual" } + ); + expect(response.status).toEqual(308); + expect(response.headers.get("Location")).toEqual("/www.example.com/"); + } + { + const response = await fetch( + `http://${ip}:${port}/%5Cwww.example.com/index/`, + { redirect: "manual" } + ); + expect(response.status).toEqual(308); + expect(response.headers.get("Location")).toEqual("/www.example.com/"); + } + { + const response = await fetch( + `http://${ip}:${port}/%09/www.example.com/index/`, + { redirect: "manual" } + ); + expect(response.status).toEqual(308); + expect(response.headers.get("Location")).toEqual("/www.example.com/"); + } + }); +}); diff --git a/fixtures/pages-simple-assets/tests/tsconfig.json b/fixtures/pages-simple-assets/tests/tsconfig.json new file mode 100644 index 0000000..a6373c6 --- /dev/null +++ b/fixtures/pages-simple-assets/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-simple-assets/tsconfig.json b/fixtures/pages-simple-assets/tsconfig.json new file mode 100644 index 0000000..570d2e9 --- /dev/null +++ b/fixtures/pages-simple-assets/tsconfig.json @@ -0,0 +1,12 @@ +{ + "include": ["index.d.ts"], + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/fixtures/pages-simple-assets/vitest.config.mts b/fixtures/pages-simple-assets/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-simple-assets/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-and-functions-app/CHANGELOG.md b/fixtures/pages-workerjs-and-functions-app/CHANGELOG.md new file mode 100644 index 0000000..c8aec36 --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/CHANGELOG.md @@ -0,0 +1,10 @@ +# pages-workerjs-and-functions-app + +## 0.0.1 + +### Patch Changes + +- [#1950](https://github.com/cloudflare/workers-sdk/pull/1950) [`daf73fbe`](https://github.com/cloudflare/workers-sdk/commit/daf73fbe03b55631383cdc86a05eac12d2775875) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - `wrangler pages dev` should prioritize `_worker.js` + + When using a `_worker.js` file, the entire `/functions` directory should be ignored – this includes its routing and middleware characteristics. Currently `wrangler pages dev` does the reverse, by prioritizing + `/functions` over `_worker.js`. These changes fix the current behaviour. diff --git a/fixtures/pages-workerjs-and-functions-app/README.md b/fixtures/pages-workerjs-and-functions-app/README.md new file mode 100644 index 0000000..ba3b95c --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/README.md @@ -0,0 +1,32 @@ +# ⚡️ pages-workerjs-and-functions-app + +`pages-workerjs-and-functions-app` is a test fixture that sets up an [Advanced Mode](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) ⚡️Pages project that also includes a `/functions` directory. This fixture is meant to test that for such projects, the single worker script provided by users will +always take precedence over the `functions` directory. + +## Publish + +> Please note that in order to deploy this project to `.pages.dev` you need to have a [Cloudflare account](https://dash.cloudflare.com/login) + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-and-functions-app + +# Deploy the directory of static assets as a Pages deployment +npx wrangler pages deploy public +``` + +If deployment was successful, follow the URL refrenced in the success message in your terminal + +``` +✨ Deployment complete! Take a peek over at https:/..pages.dev +``` + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-and-functions-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-workerjs-and-functions-app/functions/date.ts b/fixtures/pages-workerjs-and-functions-app/functions/date.ts new file mode 100644 index 0000000..0cd8f26 --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/functions/date.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response(new Date().toISOString()); +} diff --git a/fixtures/pages-workerjs-and-functions-app/functions/greeting/hello.ts b/fixtures/pages-workerjs-and-functions-app/functions/greeting/hello.ts new file mode 100644 index 0000000..d69cb1d --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/functions/greeting/hello.ts @@ -0,0 +1,3 @@ +export async function onRequest() { + return new Response("Hello world!"); +} diff --git a/fixtures/pages-workerjs-and-functions-app/package.json b/fixtures/pages-workerjs-and-functions-app/package.json new file mode 100644 index 0000000..01c96d4 --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-workerjs-functions", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8955", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-and-functions-app/public/_worker.js b/fixtures/pages-workerjs-and-functions-app/public/_worker.js new file mode 100644 index 0000000..cef8349 --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/public/_worker.js @@ -0,0 +1,24 @@ +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith("/greeting/hello")) { + return new Response("Bonjour le monde!"); + } + + if (url.pathname.startsWith("/greeting/goodbye")) { + return new Response("A plus tard alligator 👋"); + } + + if (url.pathname.startsWith("/party")) { + return new Response("Oops! Tous les alligators sont allés à la fête 🎉"); + } + + if (url.pathname.startsWith("/date")) { + return new Response( + "Yesterday is history, tomorrow is a mystery, but today is a gift. That’s why it is called the present." + ); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/fixtures/pages-workerjs-and-functions-app/public/index.html b/fixtures/pages-workerjs-and-functions-app/public/index.html new file mode 100644 index 0000000..a4c96fc --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/public/index.html @@ -0,0 +1,8 @@ + + + +

+ Bienvenue sur notre projet ✨ pages-workerjs-and-functions-app! +

+ + diff --git a/fixtures/pages-workerjs-and-functions-app/tests/index.test.ts b/fixtures/pages-workerjs-and-functions-app/tests/index.test.ts new file mode 100644 index 0000000..d72ad67 --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/tests/index.test.ts @@ -0,0 +1,50 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages project with `_worker.js` and `/functions` directory", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("renders static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-workerjs-and-functions-app!" + ); + }); + + it("runs our _worker.js and ignores the functions directory", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/greeting/hello`); + let text = await response.text(); + expect(text).toEqual("Bonjour le monde!"); + + response = await fetch(`http://${ip}:${port}/greeting/goodbye`); + text = await response.text(); + expect(text).toEqual("A plus tard alligator 👋"); + + response = await fetch(`http://${ip}:${port}/date`); + text = await response.text(); + expect(text).toEqual( + "Yesterday is history, tomorrow is a mystery, but today is a gift. That’s why it is called the present." + ); + + response = await fetch(`http://${ip}:${port}/party`); + text = await response.text(); + expect(text).toEqual("Oops! Tous les alligators sont allés à la fête 🎉"); + }); +}); diff --git a/fixtures/pages-workerjs-and-functions-app/tests/tsconfig.json b/fixtures/pages-workerjs-and-functions-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-and-functions-app/tsconfig.json b/fixtures/pages-workerjs-and-functions-app/tsconfig.json new file mode 100644 index 0000000..0ee136f --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["functions"] +} diff --git a/fixtures/pages-workerjs-and-functions-app/vitest.config.mts b/fixtures/pages-workerjs-and-functions-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-and-functions-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-app/.env b/fixtures/pages-workerjs-app/.env new file mode 100644 index 0000000..1566bb1 --- /dev/null +++ b/fixtures/pages-workerjs-app/.env @@ -0,0 +1 @@ +FOO=bar \ No newline at end of file diff --git a/fixtures/pages-workerjs-app/.gitignore b/fixtures/pages-workerjs-app/.gitignore new file mode 100644 index 0000000..1e18f27 --- /dev/null +++ b/fixtures/pages-workerjs-app/.gitignore @@ -0,0 +1 @@ +!.env \ No newline at end of file diff --git a/fixtures/pages-workerjs-app/package.json b/fixtures/pages-workerjs-app/package.json new file mode 100644 index 0000000..80df3f5 --- /dev/null +++ b/fixtures/pages-workerjs-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/pages-workerjs", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev ./workerjs-test --port 8792 --compatibility-date=2025-07-15", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-app/tests/index.test.ts b/fixtures/pages-workerjs-app/tests/index.test.ts new file mode 100644 index 0000000..d1a21b7 --- /dev/null +++ b/fixtures/pages-workerjs-app/tests/index.test.ts @@ -0,0 +1,201 @@ +import { execSync } from "node:child_process"; +import { rename } from "node:fs/promises"; +import path, { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { fetch } from "undici"; +import { describe, it, vi } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages _worker.js", () => { + it("should throw an error when the _worker.js file imports something and --bundle is false", ({ + expect, + }) => { + expect(() => + execSync("pnpm run dev -- --bundle=false", { + cwd: path.resolve(__dirname, ".."), + stdio: "ignore", + }) + ).toThrow(); + }); + + it("should throw an error when the _worker.js file imports something and --no-bundle is true", ({ + expect, + }) => { + expect(() => + execSync("pnpm run dev -- --no-bundle", { + cwd: path.resolve(__dirname, ".."), + stdio: "ignore", + }) + ).toThrow(); + }); + + it("should not throw an error when the _worker.js file imports something if --no-bundle is false", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./workerjs-test", + [ + "--no-bundle=false", + "--port=0", + "--inspector-port=0", + "--compatibility-date=2025-07-15", + ] + ); + try { + await expect( + fetch(`http://${ip}:${port}/`).then((resp) => resp.text()) + ).resolves.toContain("test"); + } finally { + await stop(); + } + }); + + it("should not throw an error when the _worker.js file imports something if --bundle is true", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./workerjs-test", + [ + "--bundle", + "--port=0", + "--inspector-port=0", + "--compatibility-date=2025-07-15", + ] + ); + try { + await expect( + fetch(`http://${ip}:${port}/`).then((resp) => resp.text()) + ).resolves.toContain("test"); + } finally { + await stop(); + } + }); + + it("should not error if the worker.js file is removed while watching", async ({ + expect, + }) => { + const basePath = resolve(__dirname, ".."); + const { ip, port, getOutput, clearOutput, stop } = + await runWranglerPagesDev(resolve(__dirname, ".."), "./workerjs-test", [ + "--port=0", + "--inspector-port=0", + "--compatibility-date=2025-07-15", + ]); + await vi.waitFor( + () => { + expect(getOutput()).toContain("Ready on"); + }, + { + timeout: 5_000, + } + ); + await setTimeout(200); + try { + clearOutput(); + await tryRename( + basePath, + "workerjs-test/_worker.js", + "workerjs-test/XXX_worker.js" + ); + await setTimeout(200); + // Expect no output since the deletion of the worker should be ignored + expect(getOutput()).toBe(""); + await tryRename( + basePath, + "workerjs-test/XXX_worker.js", + "workerjs-test/_worker.js" + ); + await setTimeout(200); + // Expect replacing the worker to now trigger a success build. + expect(getOutput()).toContain("Compiled Worker successfully"); + } finally { + await stop(); + await tryRename( + basePath, + "workerjs-test/XXX_worker.js", + "workerjs-test/_worker.js" + ); + } + }); + + it("should not error if the _routes.json file is removed while watching", async ({ + expect, + }) => { + const basePath = resolve(__dirname, ".."); + const { ip, port, getOutput, clearOutput, stop } = + await runWranglerPagesDev(resolve(__dirname, ".."), "./workerjs-test", [ + "--port=0", + "--inspector-port=0", + ]); + await vi.waitFor( + () => { + expect(getOutput()).toContain("Ready on"); + }, + { + timeout: 5_000, + } + ); + await setTimeout(200); + try { + clearOutput(); + await tryRename( + basePath, + "workerjs-test/_routes.json", + "workerjs-test/XXX_routes.json" + ); + await setTimeout(200); + // Expect no output since the deletion of the routes file should be ignored + expect(getOutput()).toBe(""); + await tryRename( + basePath, + "workerjs-test/XXX_routes.json", + "workerjs-test/_routes.json" + ); + await setTimeout(200); + // Expect replacing the routes file to trigger a build, although + // the routes build does not provide any output feedback to compare against, + // so we just check that nothing else is being printed. + expect(getOutput()).toBe(""); + } finally { + await stop(); + await tryRename( + basePath, + "workerjs-test/XXX_routes.json", + "workerjs-test/_routes.json" + ); + } + }); + + // Serendipitously, this .env reading also works for `wrangler pages dev`. + it("should read local dev vars from the .env file", async ({ expect }) => { + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "./workerjs-test", + ["--port=0", "--inspector-port=0", "--compatibility-date=2025-07-15"] + ); + try { + const response = await fetch(`http://${ip}:${port}/env`); + const env = (await response.json()) as { FOO: string }; + expect(env.FOO).toBe("bar"); + } finally { + await stop(); + } + }); + + async function tryRename( + basePath: string, + from: string, + to: string + ): Promise { + try { + await rename(resolve(basePath, from), resolve(basePath, to)); + } catch (e) { + // Do nothing if the file was not found + if ((e as any).code !== "ENOENT") { + throw e; + } + } + } +}); diff --git a/fixtures/pages-workerjs-app/tests/tsconfig.json b/fixtures/pages-workerjs-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-app/tsconfig.json b/fixtures/pages-workerjs-app/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-workerjs-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-workerjs-app/vitest.config.mts b/fixtures/pages-workerjs-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-app/workerjs-test/_routes.json b/fixtures/pages-workerjs-app/workerjs-test/_routes.json new file mode 100644 index 0000000..1cf7931 --- /dev/null +++ b/fixtures/pages-workerjs-app/workerjs-test/_routes.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "description": "", + "include": ["/*"], + "exclude": [] +} diff --git a/fixtures/pages-workerjs-app/workerjs-test/_worker.js b/fixtures/pages-workerjs-app/workerjs-test/_worker.js new file mode 100644 index 0000000..c517450 --- /dev/null +++ b/fixtures/pages-workerjs-app/workerjs-test/_worker.js @@ -0,0 +1,11 @@ +import text from "./other-script"; + +export default { + async fetch(request, env) { + if (request.url.endsWith("/env")) { + return Response.json(env); + } else { + return new Response(text); + } + }, +}; diff --git a/fixtures/pages-workerjs-app/workerjs-test/other-script.js b/fixtures/pages-workerjs-app/workerjs-test/other-script.js new file mode 100644 index 0000000..58c5715 --- /dev/null +++ b/fixtures/pages-workerjs-app/workerjs-test/other-script.js @@ -0,0 +1 @@ +export default "test"; diff --git a/fixtures/pages-workerjs-directory/CHANGELOG.md b/fixtures/pages-workerjs-directory/CHANGELOG.md new file mode 100644 index 0000000..7120a78 --- /dev/null +++ b/fixtures/pages-workerjs-directory/CHANGELOG.md @@ -0,0 +1,7 @@ +# pages-workerjs-directory + +## 0.0.1 + +### Patch Changes + +- [#3595](https://github.com/cloudflare/workers-sdk/pull/3595) [`c302bec6`](https://github.com/cloudflare/workers-sdk/commit/c302bec639c0eec10d07d6b950c0a2d3e16eab1e) Thanks [@geelen](https://github.com/geelen)! - Removing the D1 shim from the build process, in preparation for the Open Beta. D1 can now be used with --no-bundle enabled. diff --git a/fixtures/pages-workerjs-directory/package.json b/fixtures/pages-workerjs-directory/package.json new file mode 100644 index 0000000..90c6f0e --- /dev/null +++ b/fixtures/pages-workerjs-directory/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fixture/pages-workerjs-directory", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --d1=D1 --d1=PUT=elsewhere --kv KV --kv KV_REF=other_kv --r2 r2bucket --r2=R2_REF=other-r2 --port 8794", + "test:ci": "vitest run", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/add.wasm b/fixtures/pages-workerjs-directory/public/_worker.js/add.wasm new file mode 100644 index 0000000..357f72d Binary files /dev/null and b/fixtures/pages-workerjs-directory/public/_worker.js/add.wasm differ diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/index.js b/fixtures/pages-workerjs-directory/public/_worker.js/index.js new file mode 100644 index 0000000..ba1a06d --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/_worker.js/index.js @@ -0,0 +1,59 @@ +import add from "./add.wasm"; +import staticJsMod from "./static.js"; +import staticMjsMod from "./static.mjs"; + +export default { + async fetch(request, env) { + const { pathname } = new URL(request.url); + + if (pathname === "/wasm") { + const addModule = await WebAssembly.instantiate(add); + return new Response(addModule.exports.add(1, 2).toString()); + } + + if (pathname === "/static-js") { + return new Response(`static import text (via js): '${staticJsMod}'`); + } + + if (pathname === "/static-mjs") { + return new Response(`static import text (via mjs): '${staticMjsMod}'`); + } + + if (pathname === "/d1") { + const stmt1 = env.D1.prepare("SELECT 1"); + const values1 = await stmt1.first(); + + const stmt = env.PUT.prepare("SELECT 1"); + const values = await stmt.first(); + + if (JSON.stringify(values1) === JSON.stringify(values)) { + return new Response(JSON.stringify(values)); + } + + return new Response("couldn't select 1"); + } + + if (pathname === "/kv") { + await env.KV.put("key", "value"); + + await env.KV_REF.put("key", "value"); + + return new Response("saved"); + } + + if (pathname === "/r2") { + await env.r2bucket.put("key", "value"); + + await env.R2_REF.put("key", "value"); + + return new Response("saved"); + } + + if (pathname !== "/") { + const file = "." + pathname; + return new Response((await import(file)).default); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/other-other-script.mjs b/fixtures/pages-workerjs-directory/public/_worker.js/other-other-script.mjs new file mode 100644 index 0000000..23cb267 --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/_worker.js/other-other-script.mjs @@ -0,0 +1 @@ +export default "other-other-script-test"; diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/other-script.js b/fixtures/pages-workerjs-directory/public/_worker.js/other-script.js new file mode 100644 index 0000000..720d88d --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/_worker.js/other-script.js @@ -0,0 +1 @@ +export default "other-script-test"; diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/static.js b/fixtures/pages-workerjs-directory/public/_worker.js/static.js new file mode 100644 index 0000000..c4f3aa8 --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/_worker.js/static.js @@ -0,0 +1 @@ +export default "js static"; diff --git a/fixtures/pages-workerjs-directory/public/_worker.js/static.mjs b/fixtures/pages-workerjs-directory/public/_worker.js/static.mjs new file mode 100644 index 0000000..b0e9b76 --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/_worker.js/static.mjs @@ -0,0 +1 @@ +export default "mjs static"; diff --git a/fixtures/pages-workerjs-directory/public/index.html b/fixtures/pages-workerjs-directory/public/index.html new file mode 100644 index 0000000..9f735a6 --- /dev/null +++ b/fixtures/pages-workerjs-directory/public/index.html @@ -0,0 +1 @@ +

Hello, world!

diff --git a/fixtures/pages-workerjs-directory/tests/index.test.ts b/fixtures/pages-workerjs-directory/tests/index.test.ts new file mode 100644 index 0000000..6875eec --- /dev/null +++ b/fixtures/pages-workerjs-directory/tests/index.test.ts @@ -0,0 +1,92 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path, { join, resolve } from "node:path"; +import { fetch } from "undici"; +import { describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages _worker.js/ directory", () => { + it("should support non-bundling with 'dev'", async ({ expect }) => { + const tmpDir = realpathSync( + mkdtempSync(join(tmpdir(), "worker-directory-tests")) + ); + + const { ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + [ + "--port=0", + "--inspector-port=0", + `--persist-to=${tmpDir}`, + "--d1=D1", + "--d1=PUT=elsewhere", + "--kv=KV", + "--kv=KV_REF=other_kv", + "--r2=r2bucket", + "--r2=R2_REF=other-r2", + ] + ); + try { + await expect( + fetch(`http://${ip}:${port}/`).then((resp) => resp.text()) + ).resolves.toContain("Hello, world!"); + await expect( + fetch(`http://${ip}:${port}/wasm`).then((resp) => resp.text()) + ).resolves.toContain("3"); + await expect( + fetch(`http://${ip}:${port}/static-js`).then((resp) => resp.text()) + ).resolves.toEqual("static import text (via js): 'js static'"); + await expect( + fetch(`http://${ip}:${port}/static-mjs`).then((resp) => resp.text()) + ).resolves.toEqual("static import text (via mjs): 'mjs static'"); + await expect( + fetch(`http://${ip}:${port}/other-script.js`).then((resp) => + resp.text() + ) + ).resolves.toContain("other-script-test"); + await expect( + fetch(`http://${ip}:${port}/other-other-script.mjs`).then((resp) => + resp.text() + ) + ).resolves.toContain("other-other-script-test"); + await expect( + fetch(`http://${ip}:${port}/d1`).then((resp) => resp.text()) + ).resolves.toContain('{"1":1}'); + await expect( + fetch(`http://${ip}:${port}/kv`).then((resp) => resp.text()) + ).resolves.toContain("saved"); + await expect( + fetch(`http://${ip}:${port}/r2`).then((resp) => resp.text()) + ).resolves.toContain("saved"); + } finally { + await stop(); + } + + expect(existsSync(join(tmpDir, "./v3/d1"))).toBeTruthy(); + expect(existsSync(join(tmpDir, "./v3/kv"))).toBeTruthy(); + expect(existsSync(join(tmpDir, "./v3/r2"))).toBeTruthy(); + }); + + it("should bundle", ({ expect }) => { + const tempDir = realpathSync( + mkdtempSync(join(tmpdir(), "worker-bundle-tests")) + ); + const file = join(tempDir, "_worker.bundle"); + + execSync( + `npx wrangler pages functions build --build-output-directory public --outfile ${file} --bindings="{\\"d1_databases\\":{\\"D1\\":{}}}"`, + { + cwd: path.resolve(__dirname, ".."), + } + ); + + const contents = readFileSync(file, "utf-8"); + + expect(contents).not.toContain("D1_ERROR"); // No more D1 shim in the bundle! + expect(contents).toContain('"other-script-test"'); + expect(contents).toContain('"other-other-script-test"'); + expect(contents).toContain('import staticJsMod from "./static.js";'); + expect(contents).toContain('import staticMjsMod from "./static.mjs";'); + }); +}); diff --git a/fixtures/pages-workerjs-directory/tests/tsconfig.json b/fixtures/pages-workerjs-directory/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-directory/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-directory/tsconfig.json b/fixtures/pages-workerjs-directory/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-workerjs-directory/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-workerjs-directory/vitest.config.mts b/fixtures/pages-workerjs-directory/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-directory/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-wasm-app/README.md b/fixtures/pages-workerjs-wasm-app/README.md new file mode 100644 index 0000000..8242262 --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/README.md @@ -0,0 +1,41 @@ +# ⚡️ pages-workerjs-wasm-app + +`pages-workerjs-wasm-app` is a test fixture that sets up an [Advanced Mode](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) ⚡️Pages project with [`wasm` module imports](https://blog.cloudflare.com/workers-javascript-modules/) + +## Dev + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-wasm-app + +# start dev server +npm run dev +``` + +## Publish + +> Please note that in order to deploy this project to `.pages.dev` you need to have a [Cloudflare account](https://dash.cloudflare.com/login) + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-wasm-app + +# Deploy the directory of static assets as a Pages deployment +npm run publish +``` + +If deployment was successful, follow the URL refrenced in the success message in your terminal + +``` +✨ Deployment complete! Take a peek over at https:/..pages.dev +``` + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-wasm-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-workerjs-wasm-app/external-modules/meaning-of-life.html b/fixtures/pages-workerjs-wasm-app/external-modules/meaning-of-life.html new file mode 100644 index 0000000..9ee3bcd --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/external-modules/meaning-of-life.html @@ -0,0 +1,5 @@ + + + [.html]: The meaning of life is 21 + + diff --git a/fixtures/pages-workerjs-wasm-app/package.json b/fixtures/pages-workerjs-wasm-app/package.json new file mode 100644 index 0000000..eda1aba --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/pages-workerjs-wasm", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8776", + "publish": "wrangler pages deploy public", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-wasm-app/public/_worker.js b/fixtures/pages-workerjs-wasm-app/public/_worker.js new file mode 100644 index 0000000..d4ef241 --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/public/_worker.js @@ -0,0 +1,28 @@ +import html from "./../external-modules/meaning-of-life.html"; +import multiply from "./../wasm/multiply.wasm"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const multiplyModule = await WebAssembly.instantiate(multiply); + + if (url.pathname === "/meaning-of-life-wasm") { + return new Response( + `[.wasm]: The meaning of life is ${multiplyModule.exports.multiply( + 7, + 3 + )}` + ); + } + + if (url.pathname === "/meaning-of-life-html") { + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/fixtures/pages-workerjs-wasm-app/public/index.html b/fixtures/pages-workerjs-wasm-app/public/index.html new file mode 100644 index 0000000..67ae272 --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/public/index.html @@ -0,0 +1,6 @@ + + + +

Hello from pages-workerjs-wasm-app!

+ + diff --git a/fixtures/pages-workerjs-wasm-app/tests/index.test.ts b/fixtures/pages-workerjs-wasm-app/tests/index.test.ts new file mode 100644 index 0000000..e19119b --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/tests/index.test.ts @@ -0,0 +1,42 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Advanced Mode with wasm module imports", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should render static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}`); + const text = await response.text(); + expect(text).toContain("Hello from pages-workerjs-wasm-app!"); + }); + + it("should resolve wasm module imports and correctly render /meaning-of-life", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/meaning-of-life-wasm`); + const text = await response.text(); + expect(text).toEqual("[.wasm]: The meaning of life is 21"); + }); + + it("should resolve text module imports and correctly render /meaning-of-life-html", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/meaning-of-life-html`); + const text = await response.text(); + expect(text).toContain(`[.html]: The meaning of life is 21`); + }); +}); diff --git a/fixtures/pages-workerjs-wasm-app/tests/tsconfig.json b/fixtures/pages-workerjs-wasm-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-wasm-app/tsconfig.json b/fixtures/pages-workerjs-wasm-app/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-workerjs-wasm-app/vitest.config.mts b/fixtures/pages-workerjs-wasm-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-wasm-app/wasm/multiply.wasm b/fixtures/pages-workerjs-wasm-app/wasm/multiply.wasm new file mode 100644 index 0000000..0fbf927 Binary files /dev/null and b/fixtures/pages-workerjs-wasm-app/wasm/multiply.wasm differ diff --git a/fixtures/pages-workerjs-wasm-app/wasm/multiply.wat b/fixtures/pages-workerjs-wasm-app/wasm/multiply.wat new file mode 100644 index 0000000..064fbe7 --- /dev/null +++ b/fixtures/pages-workerjs-wasm-app/wasm/multiply.wat @@ -0,0 +1,7 @@ +(module + (func $multiply (param $p1 i32) (param $p2 i32) (result i32) + local.get $p1 + local.get $p2 + i32.mul) + (export "multiply" (func $multiply)) +) diff --git a/fixtures/pages-workerjs-with-config-file-app/README.md b/fixtures/pages-workerjs-with-config-file-app/README.md new file mode 100644 index 0000000..5a5623b --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/README.md @@ -0,0 +1,27 @@ +# ⚡️ pages-workerjs-with-config-file-app + +`pages-workerjs-with-config-file-app` is a test fixture that sets up an [Advanced Mode](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) ⚡️Pages⚡️ project with a `wrangler.toml` [configuration file](hhttps://developers.cloudflare.com/workers/wrangler/configuration). he purpose of this fixture is to demonstrate that `wrangler pages dev` can take configuration from a `wrangler.toml` file. + +## Local dev + +To test this fixture run `wrangler pages dev` in the fixture folder: + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-with-config-file-app + +# Start local dev server +npx wrangler pages dev +``` + +Once the local dev server was started, you should see the configuration specified in the `wrangler.toml` at the root of the fixture folder, affect the generated Worker. + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-with-config-file-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-workerjs-with-config-file-app/package.json b/fixtures/pages-workerjs-with-config-file-app/package.json new file mode 100644 index 0000000..3a817fb --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/pages-workerjs-config", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-with-config-file-app/public/_routes.json b/fixtures/pages-workerjs-with-config-file-app/public/_routes.json new file mode 100644 index 0000000..8443f0e --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/holiday", "/celebrate", "/version_metadata"], + "exclude": [] +} diff --git a/fixtures/pages-workerjs-with-config-file-app/public/_worker.js b/fixtures/pages-workerjs-with-config-file-app/public/_worker.js new file mode 100644 index 0000000..b015185 --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/public/_worker.js @@ -0,0 +1,41 @@ +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/version_metadata") { + return Response.json(env.METADATA); + } + + if (url.pathname === "/holiday") { + return new Response( + `[/holiday]:\n` + + `🎶 🎶 🎶\n` + + `If we took a ${env.VAR2}\n` + + `Took some time to ${env.VAR1}\n` + + `Just one day out of life\n` + + `It would be, it would be so nice 🎉\n` + + `🎶 🎶 🎶` + ); + } + + if (url.pathname === "/celebrate") { + return new Response( + `[/celebrate]:\n` + + `🎶 🎶 🎶\n` + + `Everybody spread the word\n` + + `We're gonna have a ${env.VAR3}\n` + + `All across the world\n` + + `In every nation\n` + + `🎶 🎶 🎶` + ); + } + + if (url.pathname === "/oh-yeah") { + return new Response( + `[/oh-yeah]: 🎶 🎶 🎶 ${env.VAR2} (ooh yeah, ooh yeah)🎶 🎶 🎶` + ); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/fixtures/pages-workerjs-with-config-file-app/public/index.html b/fixtures/pages-workerjs-with-config-file-app/public/index.html new file mode 100644 index 0000000..3e44b06 --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/public/index.html @@ -0,0 +1,6 @@ + + + +

🪩 Holiday! Celebrate! Pages supports 'wrangler.toml' 🪩

+ + diff --git a/fixtures/pages-workerjs-with-config-file-app/tests/index.test.ts b/fixtures/pages-workerjs-with-config-file-app/tests/index.test.ts new file mode 100644 index 0000000..35bdc34 --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/tests/index.test.ts @@ -0,0 +1,74 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Advanced Mode with wrangler.toml", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + undefined, + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should render static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain( + "🪩 Holiday! Celebrate! Pages supports 'wrangler.toml' 🪩" + ); + }); + + it("should run our _worker.js, and correctly apply the routing rules provided in the custom _routes.json file", async ({ + expect, + }) => { + // matches `/holiday` include rule + let response = await fetch(`http://${ip}:${port}/holiday`); + let text = await response.text(); + expect(text).toEqual( + `[/holiday]:\n` + + `🎶 🎶 🎶\n` + + `If we took a holiday\n` + + `Took some time to celebrate\n` + + `Just one day out of life\n` + + `It would be, it would be so nice 🎉\n` + + `🎶 🎶 🎶` + ); + + // matches `/celebrate` include rule + response = await fetch(`http://${ip}:${port}/celebrate`); + text = await response.text(); + expect(text).toEqual( + `[/celebrate]:\n` + + `🎶 🎶 🎶\n` + + `Everybody spread the word\n` + + `We're gonna have a celebration\n` + + `All across the world\n` + + `In every nation\n` + + `🎶 🎶 🎶` + ); + + // matches `/oh-yeah` exclude rule + response = await fetch(`http://${ip}:${port}/oh-yeah`); + text = await response.text(); + expect(text).toContain( + "🪩 Holiday! Celebrate! Pages supports 'wrangler.toml' 🪩" + ); + }); + + it("has version_metadata binding", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/version_metadata`); + + await expect(response.json()).resolves.toMatchObject({ + id: expect.any(String), + tag: expect.any(String), + }); + }); +}); diff --git a/fixtures/pages-workerjs-with-config-file-app/tests/tsconfig.json b/fixtures/pages-workerjs-with-config-file-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-with-config-file-app/tsconfig.json b/fixtures/pages-workerjs-with-config-file-app/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-workerjs-with-config-file-app/vitest.config.mts b/fixtures/pages-workerjs-with-config-file-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/pages-workerjs-with-config-file-app/wrangler.jsonc b/fixtures/pages-workerjs-with-config-file-app/wrangler.jsonc new file mode 100644 index 0000000..d9c1789 --- /dev/null +++ b/fixtures/pages-workerjs-with-config-file-app/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "pages_build_output_dir": "./public", + "name": "pages-with-config-file-app", + "compatibility_date": "2024-01-01", + "compatibility_flags": [], + "vars": { + "VAR1": "celebrate", + "VAR2": "holiday", + "VAR3": "celebration", + }, + "version_metadata": { + "binding": "METADATA", + }, +} diff --git a/fixtures/pages-workerjs-with-routes-app/CHANGELOG.md b/fixtures/pages-workerjs-with-routes-app/CHANGELOG.md new file mode 100644 index 0000000..e04ac02 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/CHANGELOG.md @@ -0,0 +1,18 @@ +# pages-workerjs-with-routes-app + +## 0.0.1 + +### Patch Changes + +- [#2065](https://github.com/cloudflare/workers-sdk/pull/2065) [`14c44588`](https://github.com/cloudflare/workers-sdk/commit/14c44588c9d22e9c9f2ad2740df57809d0cbcfbc) Thanks [@CarmenPopoviciu](https://github.com/CarmenPopoviciu)! - fix(pages): `wrangler pages dev` matches routing rules in `_routes.json` too loosely + + Currently, the logic by which we transform routing rules in `_routes.json` to + regular expressions, so we can perform `pathname` matching & routing when we + run `wrangler pages dev`, is too permissive, and leads to serving incorrect + assets for certain url paths. + + For example, a routing rule such as `/foo` will incorrectly match pathname + `/bar/foo`. Similarly, pathname `/foo` will be incorrectly matched by the + `/` routing rule. + This commit fixes our routing rule to pathname matching logic and brings + `wrangler pages dev` on par with routing in deployed Pages projects. diff --git a/fixtures/pages-workerjs-with-routes-app/README.md b/fixtures/pages-workerjs-with-routes-app/README.md new file mode 100644 index 0000000..eec745b --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/README.md @@ -0,0 +1,31 @@ +# ⚡️ pages-workerjs-with-routes-app + +`pages-workerjs-with-routes-app` is a test fixture that sets up an [Advanced Mode](https://developers.cloudflare.com/pages/platform/functions/#advanced-mode) ⚡️Pages project with a custom `_routes.json` file + +## Publish + +> Please note that in order to deploy this project to `.pages.dev` you need to have a [Cloudflare account](https://dash.cloudflare.com/login) + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-with-routes-app + +# Deploy the directory of static assets as a Pages deployment +npx wrangler pages deploy public +``` + +If deployment was successful, follow the URL refrenced in the success message in your terminal + +``` +✨ Deployment complete! Take a peek over at https:/..pages.dev +``` + +## Run tests + +```bash +# cd into the test fixture folder +cd fixtures/pages-workerjs-with-routes-app + +# Run tests +npm run test +``` diff --git a/fixtures/pages-workerjs-with-routes-app/package.json b/fixtures/pages-workerjs-with-routes-app/package.json new file mode 100644 index 0000000..7948f71 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/pages-workerjs-routes", + "private": true, + "sideEffects": false, + "scripts": { + "check:type": "tsc", + "dev": "wrangler pages dev public --port 8751", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-workerjs-with-routes-app/public/_routes.json b/fixtures/pages-workerjs-with-routes-app/public/_routes.json new file mode 100644 index 0000000..8ae2128 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/greeting/*", "/date", "/party*"], + "exclude": ["/", "/party"] +} diff --git a/fixtures/pages-workerjs-with-routes-app/public/_worker.js b/fixtures/pages-workerjs-with-routes-app/public/_worker.js new file mode 100644 index 0000000..dcb2692 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/public/_worker.js @@ -0,0 +1,41 @@ +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/") { + return new Response("ROOT"); + } + + if (url.pathname === "/party") { + return new Response( + "[/party]: Oops! Tous les alligators sont allés à la fête 🎉" + ); + } + + if (url.pathname === "/party-disco") { + return new Response("[/party-disco]: Tout le monde à la discothèque 🪩"); + } + + if (url.pathname === "/date") { + return new Response(`[/date]: ${new Date().toISOString()}`); + } + + if (url.pathname === "/greeting") { + return new Response("[/greeting]: Bonjour à tous!"); + } + + if (url.pathname === "/greeting/hello") { + return new Response("[/greeting/hello]: Bonjour le monde!"); + } + + if (url.pathname === "/greeting/bye") { + return new Response("[/greeting/bye]: A plus tard alligator 👋"); + } + + if (url.pathname === "/greetings") { + return new Response("[/greetings]: Bonjour alligators!"); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/fixtures/pages-workerjs-with-routes-app/public/index.html b/fixtures/pages-workerjs-with-routes-app/public/index.html new file mode 100644 index 0000000..260248d --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/public/index.html @@ -0,0 +1,6 @@ + + + +

Bienvenue sur notre projet ✨ pages-workerjs-with-routes-app!

+ + diff --git a/fixtures/pages-workerjs-with-routes-app/tests/index.test.ts b/fixtures/pages-workerjs-with-routes-app/tests/index.test.ts new file mode 100644 index 0000000..a537e52 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/tests/index.test.ts @@ -0,0 +1,70 @@ +import { resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerPagesDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Pages Advanced Mode with custom _routes.json", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerPagesDev( + resolve(__dirname, ".."), + "public", + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("renders static pages", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-workerjs-with-routes-app!" + ); + }); + + it("runs our _worker.js", async ({ expect }) => { + // matches /greeting/* include rule + let response = await fetch(`http://${ip}:${port}/greeting/hello`); + let text = await response.text(); + expect(text).toEqual("[/greeting/hello]: Bonjour le monde!"); + + // matches /greeting/* include rule + response = await fetch(`http://${ip}:${port}/greeting/bye`); + text = await response.text(); + expect(text).toEqual("[/greeting/bye]: A plus tard alligator 👋"); + + // matches /date include rule + response = await fetch(`http://${ip}:${port}/date`); + text = await response.text(); + expect(text).toMatch(/\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/); + + // matches both /party* include and /party exclude rules, but exclude + // has priority + response = await fetch(`http://${ip}:${port}/party`); + text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-workerjs-with-routes-app!" + ); + + // matches /party* include rule + response = await fetch(`http://${ip}:${port}/party-disco`); + text = await response.text(); + expect(text).toEqual("[/party-disco]: Tout le monde à la discothèque 🪩"); + + // matches /greeting/* include rule + response = await fetch(`http://${ip}:${port}/greeting`); + text = await response.text(); + expect(text).toEqual("[/greeting]: Bonjour à tous!"); + + // matches no rule + response = await fetch(`http://${ip}:${port}/greetings`); + text = await response.text(); + expect(text).toContain( + "Bienvenue sur notre projet ✨ pages-workerjs-with-routes-app!" + ); + }); +}); diff --git a/fixtures/pages-workerjs-with-routes-app/tests/tsconfig.json b/fixtures/pages-workerjs-with-routes-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/pages-workerjs-with-routes-app/tsconfig.json b/fixtures/pages-workerjs-with-routes-app/tsconfig.json new file mode 100644 index 0000000..7e797b5 --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["tests"] +} diff --git a/fixtures/pages-workerjs-with-routes-app/vitest.config.mts b/fixtures/pages-workerjs-with-routes-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/pages-workerjs-with-routes-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/python-worker/requirements.txt b/fixtures/python-worker/requirements.txt new file mode 100644 index 0000000..6b9ac58 --- /dev/null +++ b/fixtures/python-worker/requirements.txt @@ -0,0 +1 @@ +bcrypt==4.0.1 \ No newline at end of file diff --git a/fixtures/python-worker/src/arith.py b/fixtures/python-worker/src/arith.py new file mode 100644 index 0000000..0bcf862 --- /dev/null +++ b/fixtures/python-worker/src/arith.py @@ -0,0 +1,2 @@ +def mul(a,b): + return a*b diff --git a/fixtures/python-worker/src/index.py b/fixtures/python-worker/src/index.py new file mode 100644 index 0000000..2dd26a2 --- /dev/null +++ b/fixtures/python-worker/src/index.py @@ -0,0 +1,8 @@ +from js import Response +from other import add +from arith import mul +import bcrypt +def on_fetch(request): + password = b"super secret password" + hashed = bcrypt.hashpw(password, bcrypt.gensalt(14)) + return Response.new(f"Hi world {add(1,2)} {mul(2,3)} {hashed}") diff --git a/fixtures/python-worker/src/other.py b/fixtures/python-worker/src/other.py new file mode 100644 index 0000000..2a99cdf --- /dev/null +++ b/fixtures/python-worker/src/other.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/fixtures/python-worker/wrangler.jsonc b/fixtures/python-worker/wrangler.jsonc new file mode 100644 index 0000000..301a630 --- /dev/null +++ b/fixtures/python-worker/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "dep-python-worker", + "main": "src/index.py", + "compatibility_flags": ["python_workers"], + "compatibility_date": "2024-01-29", +} diff --git a/fixtures/ratelimit-app/.gitignore b/fixtures/ratelimit-app/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/fixtures/ratelimit-app/.gitignore @@ -0,0 +1 @@ +dist diff --git a/fixtures/ratelimit-app/package.json b/fixtures/ratelimit-app/package.json new file mode 100644 index 0000000..2ff53e9 --- /dev/null +++ b/fixtures/ratelimit-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fixture/worker-ratelimit", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "check:type": "tsc", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/ratelimit-app/src/index.js b/fixtures/ratelimit-app/src/index.js new file mode 100644 index 0000000..6bdcfe1 --- /dev/null +++ b/fixtures/ratelimit-app/src/index.js @@ -0,0 +1,27 @@ +console.log("startup log"); + +export default { + async fetch(request, env) { + console.log("request log"); + + const { pathname } = new URL(request.url); + + if (pathname.startsWith("/unsafe")) { + const { success } = await env.UNSAFE_RATE_LIMITER.limit({ + key: pathname, + }); + if (!success) { + return new Response("unsafe: Slow down", { status: 429 }); + } + return new Response("unsafe: Success"); + } + const { success } = await env.RATE_LIMITER.limit({ key: pathname }); + if (!success) { + return new Response(`Slow down`, { + status: 429, + }); + } + + return new Response("Success"); + }, +}; diff --git a/fixtures/ratelimit-app/tests/index.test.ts b/fixtures/ratelimit-app/tests/index.test.ts new file mode 100644 index 0000000..6a67353 --- /dev/null +++ b/fixtures/ratelimit-app/tests/index.test.ts @@ -0,0 +1,51 @@ +import { resolve } from "path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +const basePath = resolve(__dirname, ".."); +const server = createTestHarness({ + root: basePath, + workers: [{ configPath: "wrangler.jsonc" }], +}); + +describe("Rate limiting bindings", () => { + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("ratelimit binding is defined ", async ({ expect }) => { + let response = await server.fetch("/"); + let content = await response.text(); + expect(content).toEqual("Success"); + + response = await server.fetch("/"); + content = await response.text(); + expect(content).toEqual("Success"); + + response = await server.fetch("/"); + content = await response.text(); + expect(content).toEqual("Success"); + + response = await server.fetch("/"); + content = await response.text(); + expect(content).toEqual("Slow down"); + }); + + it("ratelimit unsafe binding is defined ", async ({ expect }) => { + let response = await server.fetch("/unsafe"); + let content = await response.text(); + expect(content).toEqual("unsafe: Success"); + + response = await server.fetch("/unsafe"); + content = await response.text(); + expect(content).toEqual("unsafe: Success"); + + response = await server.fetch("/unsafe"); + content = await response.text(); + expect(content).toEqual("unsafe: Slow down"); + }); +}); diff --git a/fixtures/ratelimit-app/tests/tsconfig.json b/fixtures/ratelimit-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/ratelimit-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/ratelimit-app/tsconfig.json b/fixtures/ratelimit-app/tsconfig.json new file mode 100644 index 0000000..655d600 --- /dev/null +++ b/fixtures/ratelimit-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "esModuleInterop": true, + "module": "preserve", + "lib": ["esnext"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/ratelimit-app/vitest.config.mts b/fixtures/ratelimit-app/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/ratelimit-app/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/ratelimit-app/wrangler.jsonc b/fixtures/ratelimit-app/wrangler.jsonc new file mode 100644 index 0000000..bbfb5bf --- /dev/null +++ b/fixtures/ratelimit-app/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "name": "ratelimit-app", + "compatibility_date": "2024-02-23", + "main": "src/index.js", + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "2001", + "simple": { + "limit": 3, + "period": 60, + }, + }, + ], + "unsafe": { + "bindings": [ + { + "name": "UNSAFE_RATE_LIMITER", + "type": "ratelimit", + "namespace_id": "1001", + "simple": { + "limit": 2, + "period": 60, + }, + }, + ], + }, +} diff --git a/fixtures/redirected-config-worker-with-environments/.gitignore b/fixtures/redirected-config-worker-with-environments/.gitignore new file mode 100644 index 0000000..1b823a2 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/.gitignore @@ -0,0 +1,2 @@ +dist +build diff --git a/fixtures/redirected-config-worker-with-environments/package.json b/fixtures/redirected-config-worker-with-environments/package.json new file mode 100644 index 0000000..62825b6 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/config-redirected-envs", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "build": "node -r esbuild-register tools/build.ts", + "check:type": "tsc", + "dev": "pnpm run build && wrangler dev", + "test:ci": "pnpm run build && vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/redirected-config-worker-with-environments/src/index.js b/fixtures/redirected-config-worker-with-environments/src/index.js new file mode 100644 index 0000000..e1ce8e6 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/src/index.js @@ -0,0 +1,5 @@ +export default { + async fetch(request, env) { + return new Response("Generated: " + env.generated ?? false); + }, +}; diff --git a/fixtures/redirected-config-worker-with-environments/tests/index.test.ts b/fixtures/redirected-config-worker-with-environments/tests/index.test.ts new file mode 100644 index 0000000..dfb7988 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/tests/index.test.ts @@ -0,0 +1,28 @@ +import { resolve } from "path"; +import { describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +const basePath = resolve(__dirname, ".."); + +describe("'wrangler dev' errors when a redirected config includes environments", () => { + it("A clear error message is presented which includes the offending environments and prompting the user to contact the tool's author", async ({ + expect, + }) => { + let error: string | undefined; + try { + await runWranglerDev(basePath, ["--port=0", "--inspector-port=0"]); + } catch (e) { + if (typeof e === "string") { + error = e; + } + } + expect(error).toBeTruthy(); + expect(error).toMatch( + /Redirected configurations cannot include environments but the following have been found:/ + ); + expect(error).toContain("staging"); + expect(error).toContain("testing"); + + expect(error).toContain("Report this issue to the tool's author"); + }); +}); diff --git a/fixtures/redirected-config-worker-with-environments/tools/build.ts b/fixtures/redirected-config-worker-with-environments/tools/build.ts new file mode 100644 index 0000000..fbfb509 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/tools/build.ts @@ -0,0 +1,36 @@ +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "fs"; + +// Create a pseudo build directory +rmSync("build", { recursive: true, force: true }); +mkdirSync("build"); +const config = { + name: "redirected-config-worker", + compatibility_date: "2024-12-01", + main: "index.js", + vars: { generated: true, environmentName: "top-level" }, + env: { + staging: { + vars: { + generated: true, + environmentName: "staging", + }, + }, + testing: { + vars: { + generated: true, + environmentName: "testing", + }, + }, + }, +}; +writeFileSync("build/wrangler.json", JSON.stringify(config, undefined, 2)); +copyFileSync("src/index.js", "build/index.js"); + +// Create the redirect file +rmSync(".wrangler/deploy", { recursive: true, force: true }); +mkdirSync(".wrangler/deploy", { recursive: true }); +const redirect = { configPath: "../../build/wrangler.json" }; +writeFileSync( + ".wrangler/deploy/config.json", + JSON.stringify(redirect, undefined, 2) +); diff --git a/fixtures/redirected-config-worker-with-environments/tsconfig.json b/fixtures/redirected-config-worker-with-environments/tsconfig.json new file mode 100644 index 0000000..7480e11 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/redirected-config-worker-with-environments/turbo.json b/fixtures/redirected-config-worker-with-environments/turbo.json new file mode 100644 index 0000000..3394ff5 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["build/**"] + } + } +} diff --git a/fixtures/redirected-config-worker-with-environments/vitest.config.mts b/fixtures/redirected-config-worker-with-environments/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/redirected-config-worker-with-environments/wrangler.jsonc b/fixtures/redirected-config-worker-with-environments/wrangler.jsonc new file mode 100644 index 0000000..7e57473 --- /dev/null +++ b/fixtures/redirected-config-worker-with-environments/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "redirected-config-worker", + "compatibility_date": "2024-12-01", + "main": "src/index.js", +} diff --git a/fixtures/redirected-config-worker/.gitignore b/fixtures/redirected-config-worker/.gitignore new file mode 100644 index 0000000..2cf1da4 --- /dev/null +++ b/fixtures/redirected-config-worker/.gitignore @@ -0,0 +1,2 @@ +dist +build \ No newline at end of file diff --git a/fixtures/redirected-config-worker/package.json b/fixtures/redirected-config-worker/package.json new file mode 100644 index 0000000..10d1e9f --- /dev/null +++ b/fixtures/redirected-config-worker/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/config-redirected", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "build": "node -r esbuild-register tools/build.ts", + "check:type": "tsc", + "dev": "pnpm run build && wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/redirected-config-worker/src/index.js b/fixtures/redirected-config-worker/src/index.js new file mode 100644 index 0000000..3031001 --- /dev/null +++ b/fixtures/redirected-config-worker/src/index.js @@ -0,0 +1,5 @@ +export default { + async fetch(request, env) { + return new Response("Generated: " + (env.generated ?? false)); + }, +}; diff --git a/fixtures/redirected-config-worker/tests/index.test.ts b/fixtures/redirected-config-worker/tests/index.test.ts new file mode 100644 index 0000000..7b3a78b --- /dev/null +++ b/fixtures/redirected-config-worker/tests/index.test.ts @@ -0,0 +1,140 @@ +import { execSync, spawnSync } from "child_process"; +import { resolve } from "path"; +import { fetch } from "undici"; +import { describe, it, onTestFinished } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +const basePath = resolve(__dirname, ".."); + +describe("'wrangler dev', when reading redirected config,", () => { + it("uses the generated config", async ({ expect }) => { + build("prod"); + const { ip, port, stop } = await runWranglerDev(basePath, [ + "--port=0", + "--inspector-port=0", + ]); + onTestFinished(async () => await stop?.()); + + // Note that the local protocol defaults to http + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(`"Generated: prod"`); + }); + + it("works when specifying the same environment via CLI arg to the one used in build", async ({ + expect, + }) => { + build("production"); + + const { ip, port, stop } = await runWranglerDev(basePath, [ + "--port=0", + "--inspector-port=0", + "--env=production", + ]); + onTestFinished(async () => await stop?.()); + + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(`"Generated: production"`); + }); + + it("errors when specifying a different environment via CLI arg to the one used in build", async ({ + expect, + }) => { + build("production"); + + const error = await runWranglerDev(basePath, [ + "--port=0", + "--inspector-port=0", + "--env=staging", + ]).then( + // it is doesn't error then stop the process + ({ stop }) => stop(), + (e) => e + ); + + expect(error).toMatch( + 'You have specified the environment "staging" via the `--env/-e` CLI argument.' + ); + expect(error).toMatch( + 'This does not match the target environment "production" that was used when building the application.' + ); + expect(error).toMatch( + 'Perhaps you need to re-run the custom build of the project with "staging" as the selected environment?' + ); + }); + + it("errors when specifying a different environment via CLOUDFLARE_ENV to the one used in build", async ({ + expect, + }) => { + build("production"); + + let error = ""; + try { + await runWranglerDev(basePath, ["--port=0", "--inspector-port=0"], { + CLOUDFLARE_ENV: "staging", + }); + } catch (e) { + error = e as string; + } + + expect(error).toMatch( + 'You have specified the environment "staging" via the CLOUDFLARE_ENV environment variable.' + ); + expect(error).toMatch( + 'This does not match the target environment "production" that was used when building the application.' + ); + expect(error).toMatch( + 'Perhaps you need to re-run the custom build of the project with "staging" as the selected environment?' + ); + }); + + it("uses a custom config from command line rather than generated config", async ({ + expect, + }) => { + const { ip, port, stop } = await runWranglerDev(basePath, [ + "-c=wrangler.jsonc", + "--port=0", + "--inspector-port=0", + ]); + onTestFinished(async () => await stop?.()); + + // Note that the local protocol defaults to http + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(`"Generated: false"`); + }); +}); + +describe("'wrangler deploy', when reading redirected config,", () => { + it("uses the generated config", ({ expect }) => { + build("prod"); + const output = spawnSync("pnpm", ["wrangler", "deploy", "--dry-run"], { + cwd: basePath, + stdio: "pipe", + shell: true, + encoding: "utf-8", + }); + expect(output.stdout).toContain(`Using redirected Wrangler configuration.`); + expect(output.stdout.replace(/\\/g, "/")).toContain( + ` - Configuration being used: "build/wrangler.json"` + ); + expect(output.stdout).toContain( + ` - Original user's configuration: "wrangler.jsonc"` + ); + expect(output.stdout.replace(/\\/g, "/")).toContain( + ` - Deploy configuration file: ".wrangler/deploy/config.json"` + ); + expect(output.stderr).toMatchInlineSnapshot(`""`); + }); +}); + +function build(env: string) { + execSync("node -r esbuild-register tools/build.ts", { + cwd: basePath, + env: { ...process.env, CLOUDFLARE_ENV: env }, + }); +} diff --git a/fixtures/redirected-config-worker/tools/build.ts b/fixtures/redirected-config-worker/tools/build.ts new file mode 100644 index 0000000..70f69cd --- /dev/null +++ b/fixtures/redirected-config-worker/tools/build.ts @@ -0,0 +1,24 @@ +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "fs"; + +// Create a pseudo build directory +rmSync("build", { recursive: true, force: true }); +mkdirSync("build"); +const config = { + name: "redirected-config-worker", + compatibility_date: "2024-12-01", + main: "index.js", + definedEnvironments: ["prod", "staging"], + targetEnvironment: process.env.CLOUDFLARE_ENV, + vars: { generated: process.env.CLOUDFLARE_ENV ?? "none" }, +}; +writeFileSync("build/wrangler.json", JSON.stringify(config, undefined, 2)); +copyFileSync("src/index.js", "build/index.js"); + +// Create the redirect file +rmSync(".wrangler/deploy", { recursive: true, force: true }); +mkdirSync(".wrangler/deploy", { recursive: true }); +const redirect = { configPath: "../../build/wrangler.json" }; +writeFileSync( + ".wrangler/deploy/config.json", + JSON.stringify(redirect, undefined, 2) +); diff --git a/fixtures/redirected-config-worker/tsconfig.json b/fixtures/redirected-config-worker/tsconfig.json new file mode 100644 index 0000000..7480e11 --- /dev/null +++ b/fixtures/redirected-config-worker/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/redirected-config-worker/turbo.json b/fixtures/redirected-config-worker/turbo.json new file mode 100644 index 0000000..3394ff5 --- /dev/null +++ b/fixtures/redirected-config-worker/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["build/**"] + } + } +} diff --git a/fixtures/redirected-config-worker/vitest.config.mts b/fixtures/redirected-config-worker/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/redirected-config-worker/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/redirected-config-worker/wrangler.jsonc b/fixtures/redirected-config-worker/wrangler.jsonc new file mode 100644 index 0000000..f63df02 --- /dev/null +++ b/fixtures/redirected-config-worker/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "name": "redirected-config-worker", + "compatibility_date": "2024-12-01", + "main": "src/index.js", + "env": { + "staging": { + "vars": { + "generated": "none", + }, + }, + "prod": { + "vars": { + "generated": "none", + }, + }, + }, +} diff --git a/fixtures/routing-app/functions/[default].js b/fixtures/routing-app/functions/[default].js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/[default].js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/_middleware.js b/fixtures/routing-app/functions/_middleware.js new file mode 100644 index 0000000..f68d175 --- /dev/null +++ b/fixtures/routing-app/functions/_middleware.js @@ -0,0 +1,15 @@ +const errorHandler = async ({ next }) => { + try { + return await next(); + } catch (err) { + return new Response(`${err.message}\n${err.stack}`, { status: 500 }); + } +}; + +const hello = async ({ next }) => { + const response = await next(); + response.headers.set("X-Hello", "Hello from functions Middleware!"); + return response; +}; + +export const onRequest = [errorHandler, hello]; diff --git a/fixtures/routing-app/functions/bar/[stuff].js b/fixtures/routing-app/functions/bar/[stuff].js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/bar/[stuff].js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/bar/baz/[[all]].js b/fixtures/routing-app/functions/bar/baz/[[all]].js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/bar/baz/[[all]].js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/bar/foo.js b/fixtures/routing-app/functions/bar/foo.js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/bar/foo.js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/blah.js b/fixtures/routing-app/functions/blah.js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/blah.js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/foo.js b/fixtures/routing-app/functions/foo.js new file mode 100644 index 0000000..671e2ed --- /dev/null +++ b/fixtures/routing-app/functions/foo.js @@ -0,0 +1,13 @@ +export async function onRequest(context) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/functions/index.ts b/fixtures/routing-app/functions/index.ts new file mode 100644 index 0000000..91121fe --- /dev/null +++ b/fixtures/routing-app/functions/index.ts @@ -0,0 +1,15 @@ +export async function onRequest( + context: EventContext> +) { + // Contents of context object + const { + request, // same as existing Worker API + env, // same as existing Worker API + params, // if filename includes [id] or [[path]] + waitUntil, // same as ctx.waitUntil in existing Worker API + next, // used for middleware or to fetch assets + data, // arbitrary space for passing data between middlewares + } = context; + + return new Response("Hello, world!"); +} diff --git a/fixtures/routing-app/package.json b/fixtures/routing-app/package.json new file mode 100644 index 0000000..79cdadc --- /dev/null +++ b/fixtures/routing-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/routing-basic", + "private": true, + "description": "routing-app for testing", + "scripts": { + "check:type": "tsc" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default" + } +} diff --git a/fixtures/routing-app/public/_routes.json b/fixtures/routing-app/public/_routes.json new file mode 100644 index 0000000..5d15c52 --- /dev/null +++ b/fixtures/routing-app/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/blah/"], + "exclude": ["/abc"] +} diff --git a/fixtures/routing-app/public/index.html b/fixtures/routing-app/public/index.html new file mode 100644 index 0000000..8c9051f --- /dev/null +++ b/fixtures/routing-app/public/index.html @@ -0,0 +1 @@ +

Hello world! 1

diff --git a/fixtures/routing-app/tsconfig.json b/fixtures/routing-app/tsconfig.json new file mode 100644 index 0000000..f8adbec --- /dev/null +++ b/fixtures/routing-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/rules-app/md.d.ts b/fixtures/rules-app/md.d.ts new file mode 100644 index 0000000..a47bd62 --- /dev/null +++ b/fixtures/rules-app/md.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/fixtures/rules-app/package.json b/fixtures/rules-app/package.json new file mode 100644 index 0000000..654d1c2 --- /dev/null +++ b/fixtures/rules-app/package.json @@ -0,0 +1,14 @@ +{ + "name": "@fixture/rules-basic", + "private": true, + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "scripts": { + "check:type": "tsc" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default" + } +} diff --git a/fixtures/rules-app/src/content.md b/fixtures/rules-app/src/content.md new file mode 100644 index 0000000..2ef267e --- /dev/null +++ b/fixtures/rules-app/src/content.md @@ -0,0 +1 @@ +some content diff --git a/fixtures/rules-app/src/index.ts b/fixtures/rules-app/src/index.ts new file mode 100644 index 0000000..faddd04 --- /dev/null +++ b/fixtures/rules-app/src/index.ts @@ -0,0 +1,17 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `wrangler dev src/index.ts` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `wrangler deploy src/index.ts --name my-worker` to deploy your worker + * + * Learn more at https://developers.cloudflare.com/workers/ + */ + +import content from "./content.md"; + +export default { + async fetch(request: Request): Promise { + return new Response(`${request.url}: ${content}`); + }, +}; diff --git a/fixtures/rules-app/tsconfig.json b/fixtures/rules-app/tsconfig.json new file mode 100644 index 0000000..f8adbec --- /dev/null +++ b/fixtures/rules-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/rules-app/wrangler.jsonc b/fixtures/rules-app/wrangler.jsonc new file mode 100644 index 0000000..85f7388 --- /dev/null +++ b/fixtures/rules-app/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "rules-app", + "compatibility_date": "2022-02-28", + "main": "src/index.ts", + "rules": [ + { + "type": "Text", + "globs": ["**/*.md"], + "fallthrough": true, + }, + ], +} diff --git a/fixtures/secrets-store/package.json b/fixtures/secrets-store/package.json new file mode 100644 index 0000000..e84623e --- /dev/null +++ b/fixtures/secrets-store/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/secrets-store", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/secrets-store/src/index.ts b/fixtures/secrets-store/src/index.ts new file mode 100644 index 0000000..180d90c --- /dev/null +++ b/fixtures/secrets-store/src/index.ts @@ -0,0 +1,23 @@ +export interface Env { + SECRET: any; +} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise { + try { + const value = await env.SECRET.get(); + return new Response(value); + } catch (e) { + return new Response( + e instanceof Error ? e.message : "Something went wrong", + { + status: 404, + } + ); + } + }, +}; diff --git a/fixtures/secrets-store/tsconfig.json b/fixtures/secrets-store/tsconfig.json new file mode 100644 index 0000000..2431bac --- /dev/null +++ b/fixtures/secrets-store/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "types": ["@cloudflare/workers-types/experimental"], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/secrets-store/wrangler.jsonc b/fixtures/secrets-store/wrangler.jsonc new file mode 100644 index 0000000..b014452 --- /dev/null +++ b/fixtures/secrets-store/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "compatibility_date": "2025-01-01", + "main": "src/index.ts", + "name": "secret-store", + "secrets_store_secrets": [ + { + "binding": "SECRET", + "secret_name": "secret_name", + "store_id": "a3fb907f446e4d94bd946d7ea6365b1c", + }, + ], +} diff --git a/fixtures/shared/package.json b/fixtures/shared/package.json new file mode 100644 index 0000000..d1e3d80 --- /dev/null +++ b/fixtures/shared/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/shared", + "private": true, + "description": "Shared fixtures for testing", + "devDependencies": { + "@cloudflare/workers-utils": "workspace:*", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/shared/src/fs-helpers.ts b/fixtures/shared/src/fs-helpers.ts new file mode 100644 index 0000000..e23ce71 --- /dev/null +++ b/fixtures/shared/src/fs-helpers.ts @@ -0,0 +1,3 @@ +// Re-exported so that fixtures can import from `@fixture/shared/src/fs-helpers` +// without each fixture needing a direct dependency on `@cloudflare/workers-utils`. +export { removeDir, removeDirSync } from "@cloudflare/workers-utils"; diff --git a/fixtures/shared/src/mock-console.ts b/fixtures/shared/src/mock-console.ts new file mode 100644 index 0000000..85283e4 --- /dev/null +++ b/fixtures/shared/src/mock-console.ts @@ -0,0 +1,60 @@ +import * as util from "node:util"; +import { afterEach, beforeEach, vi } from "vitest"; +import type { MockInstance } from "vitest"; + +/** + * We use this module to mock console methods, and optionally + * assert on the values they're called with in our tests. + */ + +let debugSpy: MockInstance, + logSpy: MockInstance, + infoSpy: MockInstance, + errorSpy: MockInstance, + warnSpy: MockInstance; + +const std = { + get debug() { + return normalizeOutput(debugSpy); + }, + get out() { + return normalizeOutput(logSpy); + }, + get info() { + return normalizeOutput(infoSpy); + }, + get err() { + return normalizeOutput(errorSpy); + }, + get warn() { + return normalizeOutput(warnSpy); + }, +}; + +function normalizeOutput(spy: MockInstance, join = "\n"): string { + return captureCalls(spy, join); +} + +function captureCalls(spy: MockInstance, join = "\n"): string { + return spy.mock.calls + .map((args: unknown[]) => util.format("%s", ...args)) + .join(join); +} + +export function mockConsoleMethods() { + beforeEach(() => { + debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + debugSpy.mockRestore(); + logSpy.mockRestore(); + infoSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + return std; +} diff --git a/fixtures/shared/src/mock-postgres-server.ts b/fixtures/shared/src/mock-postgres-server.ts new file mode 100644 index 0000000..511ac19 --- /dev/null +++ b/fixtures/shared/src/mock-postgres-server.ts @@ -0,0 +1,212 @@ +import assert from "node:assert"; +import events from "node:events"; +import net from "node:net"; +import util from "node:util"; + +/** + * The 8-byte Postgres SSLRequest packet. + * Length=8, Code=80877103 (0x04D2162F). + */ +export const POSTGRES_SSL_REQUEST_PACKET = Buffer.from([ + 0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f, +]); + +// ── Postgres wire protocol helper functions ────────────────────────────────── + +/** Build a single Postgres backend message: type (1 byte) + int32 length + payload. */ +function pgMsg(type: string, payload: Buffer): Buffer { + const len = 4 + payload.length; // length field includes itself but not the type byte + const buf = Buffer.alloc(1 + 4 + payload.length); + buf.write(type, 0, 1, "ascii"); + buf.writeInt32BE(len, 1); + payload.copy(buf, 5); + return buf; +} + +/** AuthenticationOk — type 'R', auth status 0. */ +function authenticationOk(): Buffer { + const payload = Buffer.alloc(4); + payload.writeInt32BE(0, 0); // auth type 0 = OK + return pgMsg("R", payload); +} + +/** ParameterStatus — type 'S', name + value (both null-terminated). */ +function parameterStatus(name: string, value: string): Buffer { + const payload = Buffer.from(`${name}\0${value}\0`, "utf8"); + return pgMsg("S", payload); +} + +/** BackendKeyData — type 'K', process ID + secret key. */ +function backendKeyData(pid: number, key: number): Buffer { + const payload = Buffer.alloc(8); + payload.writeInt32BE(pid, 0); + payload.writeInt32BE(key, 4); + return pgMsg("K", payload); +} + +/** ReadyForQuery — type 'Z', transaction status indicator. */ +function readyForQuery(status: "I" | "T" | "E" = "I"): Buffer { + return pgMsg("Z", Buffer.from(status, "ascii")); +} + +/** RowDescription — type 'T', field definitions for one or more columns. */ +function rowDescription(columns: string[]): Buffer { + // int16 field count, then for each field: + // name (null-terminated), table OID (int32), column attr (int16), + // type OID (int32), type size (int16), type modifier (int32), format code (int16) + const parts: Buffer[] = []; + const fieldCount = Buffer.alloc(2); + fieldCount.writeInt16BE(columns.length, 0); + parts.push(fieldCount); + + for (const col of columns) { + const name = Buffer.from(`${col}\0`, "utf8"); + const meta = Buffer.alloc(18); + meta.writeInt32BE(0, 0); // table OID + meta.writeInt16BE(0, 4); // column attribute number + meta.writeInt32BE(25, 6); // type OID: 25 = text + meta.writeInt16BE(-1, 10); // type size: -1 = variable length + meta.writeInt32BE(-1, 12); // type modifier + meta.writeInt16BE(0, 16); // format code: 0 = text + parts.push(name, meta); + } + + return pgMsg("T", Buffer.concat(parts)); +} + +/** DataRow — type 'D', column values as text. */ +function dataRow(values: string[]): Buffer { + const parts: Buffer[] = []; + const colCount = Buffer.alloc(2); + colCount.writeInt16BE(values.length, 0); + parts.push(colCount); + + for (const val of values) { + const valBuf = Buffer.from(val, "utf8"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeInt32BE(valBuf.length, 0); + parts.push(lenBuf, valBuf); + } + + return pgMsg("D", Buffer.concat(parts)); +} + +/** CommandComplete — type 'C', command tag (null-terminated). */ +function commandComplete(tag: string): Buffer { + return pgMsg("C", Buffer.from(`${tag}\0`, "utf8")); +} + +// ── Mock server ────────────────────────────────────────────────────────────── + +export interface MockPgServerOptions { + /** Canned rows to return for any query. Defaults to [{ id: "1" }]. */ + rows?: Record[]; +} + +/** + * Creates a minimal mock Postgres server that speaks enough of the wire protocol + * for the `pg` (node-postgres) library to successfully connect, authenticate, + * and execute simple queries. + * + * Handles: SSLRequest, StartupMessage, SimpleQuery (Q), Terminate (X). + * Returns canned result rows for any query. + */ +export async function createMockPostgresServer( + options?: MockPgServerOptions +): Promise<{ server: net.Server; port: number; stop: () => Promise }> { + const rows = options?.rows ?? [{ id: "1" }]; + + const server = net.createServer((socket) => { + let startupHandled = false; + + socket.on("data", (chunk: Buffer) => { + // 1. SSLRequest — respond with 'N' (no SSL) + if (POSTGRES_SSL_REQUEST_PACKET.equals(chunk)) { + socket.write("N"); + return; + } + + // 2. StartupMessage — no type byte prefix; starts with int32 length + int32 version (196608 = 3.0) + if (!startupHandled) { + // Validate it looks like a StartupMessage (version 3.0) + if (chunk.length >= 8) { + const version = chunk.readInt32BE(4); + if (version === 196608) { + startupHandled = true; + socket.write( + Buffer.concat([ + authenticationOk(), + parameterStatus("server_version", "15.0"), + parameterStatus("client_encoding", "UTF8"), + backendKeyData(1, 1), + readyForQuery("I"), + ]) + ); + return; + } + } + } + + // 3. Parse typed messages (first byte = type, next 4 bytes = length) + let offset = 0; + while (offset < chunk.length) { + const typeByte = chunk[offset]; + if (typeByte === undefined || offset + 5 > chunk.length) { + break; + } + const msgType = String.fromCharCode(typeByte); + const msgLen = chunk.readInt32BE(offset + 1); + // Total message size = 1 (type) + msgLen + const msgEnd = offset + 1 + msgLen; + + switch (msgType) { + case "Q": { + // SimpleQuery — return canned rows + const firstRow = rows[0]; + const columns = firstRow ? Object.keys(firstRow) : []; + const parts: Buffer[] = [rowDescription(columns)]; + + for (const row of rows) { + parts.push(dataRow(columns.map((col) => row[col] ?? ""))); + } + + parts.push(commandComplete(`SELECT ${rows.length}`)); + parts.push(readyForQuery("I")); + socket.write(Buffer.concat(parts)); + break; + } + case "X": { + // Terminate + socket.end(); + return; + } + default: { + // Unknown message — ignore + break; + } + } + + offset = msgEnd; + } + }); + + // Prevent socket errors (e.g. ECONNRESET from abrupt client disconnects) + // from becoming uncaught exceptions — Node.js EventEmitters throw if an + // 'error' event fires with no listener attached. + socket.on("error", () => {}); + }); + + const listeningPromise = events.once(server, "listening"); + server.listen(0, "127.0.0.1"); + await listeningPromise; + + const address = server.address(); + assert(typeof address === "object" && address !== null); + const port = address.port; + + const stop = async () => { + await util.promisify(server.close.bind(server))(); + }; + + return { server, port, stop }; +} diff --git a/fixtures/shared/src/run-wrangler-long-lived.ts b/fixtures/shared/src/run-wrangler-long-lived.ts new file mode 100644 index 0000000..8e4c8e1 --- /dev/null +++ b/fixtures/shared/src/run-wrangler-long-lived.ts @@ -0,0 +1,296 @@ +import assert from "node:assert"; +import { fork } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import treeKill from "tree-kill"; + +export const wranglerEntryPath = path.resolve( + __dirname, + "../../../packages/wrangler/bin/wrangler.js" +); + +/** + * Returns a per-process temporary directory for the dev registry. + * + * When multiple fixture tests run in parallel (turbo `--concurrency=2`), each + * fixture's vitest process gets its own isolated registry directory. This + * prevents cross-fixture leakage — e.g. the local-explorer in one fixture + * picking up workers registered by a completely unrelated fixture. + * + * Within a single fixture process, all `runWranglerDev` / `runWranglerPagesDev` + * calls share the same registry so multi-worker setups (service bindings, + * durable objects across workers) continue to work. + * + * See `packages/miniflare/src/shared/DEV_REGISTRY.md` for registry architecture. + */ +let isolatedRegistryPath: string | undefined; +function getIsolatedRegistryPath(): string { + if (isolatedRegistryPath === undefined) { + isolatedRegistryPath = mkdtempSync( + path.join(os.tmpdir(), "wrangler-fixture-registry-") + ); + process.on("exit", () => { + try { + rmSync(isolatedRegistryPath!, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; the OS will reap tmp dirs eventually. + } + }); + } + return isolatedRegistryPath; +} + +/** + * Runs the command `wrangler pages dev` in a child process. + * + * Returns an object that gives you access to: + * + * - `ip` and `port` of the http-server hosting the pages project + * - `stop()` function that will close down the server. + */ +export async function runWranglerPagesDev( + cwd: string, + publicPath: string | undefined, + options: string[], + env?: NodeJS.ProcessEnv +) { + if (publicPath) { + return runLongLivedWrangler( + ["pages", "dev", publicPath, "--ip=127.0.0.1", ...options], + cwd, + env + ); + } else { + return runLongLivedWrangler( + ["pages", "dev", "--ip=127.0.0.1", ...options], + cwd, + env + ); + } +} + +/** + * Runs the command `wrangler dev` in a child process. + * + * Returns an object that gives you access to: + * + * - `ip` and `port` of the http-server hosting the pages project + * - `stop()` function that will close down the server. + */ +export async function runWranglerDev( + cwd: string, + options: string[], + env?: NodeJS.ProcessEnv +) { + return runLongLivedWrangler(["dev", "--ip=127.0.0.1", ...options], cwd, env); +} + +async function runLongLivedWrangler( + command: string[], + cwd: string, + env?: NodeJS.ProcessEnv +) { + let settledReadyPromise = false; + let resolveReadyPromise: (value: { ip: string; port: number }) => void; + let rejectReadyPromise: (reason: unknown) => void; + let processExited = false; + let stopping = false; + + const ready = new Promise<{ ip: string; port: number }>((resolve, reject) => { + resolveReadyPromise = resolve; + rejectReadyPromise = reject; + }); + + // Ensure each fixture process uses an isolated dev registry unless the + // caller (or the environment) has already specified one explicitly. + const registryEnv: NodeJS.ProcessEnv = {}; + if (!env?.WRANGLER_REGISTRY_PATH && !process.env.WRANGLER_REGISTRY_PATH) { + registryEnv.WRANGLER_REGISTRY_PATH = getIsolatedRegistryPath(); + } + if (!env?.MINIFLARE_REGISTRY_PATH && !process.env.MINIFLARE_REGISTRY_PATH) { + registryEnv.MINIFLARE_REGISTRY_PATH = getIsolatedRegistryPath(); + } + + const wranglerProcess = fork(wranglerEntryPath, command, { + stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"], + cwd, + env: { ...process.env, ...registryEnv, ...env, PWD: cwd }, + }).on("message", (message) => { + if (settledReadyPromise) { + return; + } + settledReadyPromise = true; + clearTimeout(timeoutHandle); + resolveReadyPromise(JSON.parse(message.toString())); + }); + + const chunks: Buffer[] = []; + wranglerProcess.stdout?.on("data", (chunk) => { + if (process.env.WRANGLER_LOG === "debug") { + console.log(`[${command}]`, chunk.toString()); + } + chunks.push(chunk); + }); + wranglerProcess.stderr?.on("data", (chunk) => { + if (process.env.WRANGLER_LOG === "debug") { + console.log(`[${command}]`, chunk.toString()); + } + chunks.push(chunk); + }); + const getOutput = () => Buffer.concat(chunks).toString(); + const clearOutput = () => (chunks.length = 0); + + wranglerProcess.once("exit", (exitCode, signal) => { + processExited = true; + if (!settledReadyPromise) { + settledReadyPromise = true; + rejectReadyPromise( + `Wrangler exited with error code: ${exitCode}\nOutput: ${getOutput()}` + ); + return; + } + if (stopping) { + // Exit was triggered by `stop()` (normal test teardown). No diagnostic + // needed — the tests are already done. + return; + } + // The process exited *after* we sent back a ready signal — any pending + // tests will now see ECONNREFUSED. Dump the captured output so CI logs + // contain the diagnostic info needed to understand what happened. + const separator = "=".repeat(80); + console.error( + [ + `Wrangler process exited unexpectedly after startup (code=${exitCode}, signal=${signal})`, + `Command: ${command.join(" ")}`, + separator, + getOutput(), + separator, + ].join("\n") + ); + }); + + const timeoutHandle = setTimeout(() => { + if (settledReadyPromise) { + return; + } + settledReadyPromise = true; + const separator = "=".repeat(80); + const message = [ + "Timed out starting long-lived Wrangler:", + separator, + getOutput(), + separator, + ].join("\n"); + rejectReadyPromise(new Error(message)); + }, 50_000); + + async function stop() { + stopping = true; + return new Promise((resolve) => { + if (processExited) { + // Already dead — nothing to kill. Avoid noisy Windows taskkill errors. + resolve(); + return; + } + assert( + wranglerProcess.pid, + `Command "${command.join(" ")}" had no process id` + ); + treeKill(wranglerProcess.pid, (e) => { + if (e) { + console.error( + "Failed to kill command: " + command.join(" "), + wranglerProcess.pid, + e + ); + } + // fallthrough to resolve() because either the process is already dead + // or don't have permission to kill it or some other reason? + // either way, there is nothing we can do and we don't want to fail the test because of this + resolve(); + }); + }); + } + + const { ip, port } = await ready; + + // Close the race between Wrangler's IPC "ready" message and the TCP socket + // actually accepting connections. Without this, tests can run immediately + // after `beforeAll` returns and hit ECONNREFUSED — especially under CI load + // when fixtures run in parallel (see ci-flake label for prior art). If the + // server never starts listening, surface a clear error with captured output + // instead of letting every test in the suite fail with ECONNREFUSED. + await waitForServerListening(ip, port, getOutput, () => processExited, stop); + + return { ip, port, stop, getOutput, clearOutput }; +} + +/** + * Polls `ip:port` until it accepts a TCP connection, giving up after + * `totalTimeoutMs`. Throws with a diagnostic message (including the captured + * Wrangler output) if the server never starts listening or the process exits. + */ +async function waitForServerListening( + ip: string, + port: number, + getOutput: () => string, + hasExited: () => boolean, + stop: () => Promise, + totalTimeoutMs = 10_000 +) { + const deadline = Date.now() + totalTimeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + if (hasExited()) { + break; + } + try { + await tryConnect(ip, port); + return; + } catch (e) { + lastError = e; + await delay(100); + } + } + await stop(); + const separator = "=".repeat(80); + throw new Error( + [ + `Wrangler reported ready on ${ip}:${port} but the server is not accepting connections (last error: ${lastError instanceof Error ? lastError.message : String(lastError)})`, + separator, + getOutput(), + separator, + ].join("\n") + ); +} + +function tryConnect(ip: string, port: number, timeoutMs = 500): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host: ip, port }); + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const onConnect = () => { + cleanup(); + resolve(); + }; + const onTimeout = () => { + cleanup(); + reject(new Error("TCP connect timed out")); + }; + const cleanup = () => { + socket.removeListener("error", onError); + socket.removeListener("connect", onConnect); + socket.removeListener("timeout", onTimeout); + socket.destroy(); + }; + socket.setTimeout(timeoutMs); + socket.once("error", onError); + socket.once("connect", onConnect); + socket.once("timeout", onTimeout); + }); +} diff --git a/fixtures/sites-app/package.json b/fixtures/sites-app/package.json new file mode 100644 index 0000000..42227f2 --- /dev/null +++ b/fixtures/sites-app/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/sites-basic", + "private": true, + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "main": "index.js", + "devDependencies": { + "@cloudflare/kv-asset-handler": "workspace:*" + } +} diff --git a/fixtures/sites-app/public/404.html b/fixtures/sites-app/public/404.html new file mode 100644 index 0000000..5ac9921 --- /dev/null +++ b/fixtures/sites-app/public/404.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+

404 Not Found

+

Oh dang! We couldn't find that page.

+ a sad crab is unable to unable to lasso a paper airplane. 404 not found. +
+ + diff --git a/fixtures/sites-app/public/favicon.ico b/fixtures/sites-app/public/favicon.ico new file mode 100644 index 0000000..cc6c23b Binary files /dev/null and b/fixtures/sites-app/public/favicon.ico differ diff --git a/fixtures/sites-app/public/img/200-wrangler-ferris.gif b/fixtures/sites-app/public/img/200-wrangler-ferris.gif new file mode 100644 index 0000000..8853751 Binary files /dev/null and b/fixtures/sites-app/public/img/200-wrangler-ferris.gif differ diff --git a/fixtures/sites-app/public/img/404-wrangler-ferris.gif b/fixtures/sites-app/public/img/404-wrangler-ferris.gif new file mode 100644 index 0000000..0ac1479 Binary files /dev/null and b/fixtures/sites-app/public/img/404-wrangler-ferris.gif differ diff --git a/fixtures/sites-app/public/index.html b/fixtures/sites-app/public/index.html new file mode 100644 index 0000000..102985f --- /dev/null +++ b/fixtures/sites-app/public/index.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+

200 Success

+

Hello World! Welcome to your Workers Site.

+ a happy crab is wearing a cowboy hat and holding a lasso. 200 success. +
+ + diff --git a/fixtures/sites-app/src/modules.js b/fixtures/sites-app/src/modules.js new file mode 100644 index 0000000..7073b4b --- /dev/null +++ b/fixtures/sites-app/src/modules.js @@ -0,0 +1,88 @@ +import { + getAssetFromKV, + mapRequestToAsset, +} from "@cloudflare/kv-asset-handler"; +import manifestJSON from "__STATIC_CONTENT_MANIFEST"; + +const assetManifest = JSON.parse(manifestJSON); + +/** + * The DEBUG flag will do two things that help during development: + * 1. we will skip caching on the edge, which makes it easier to + * debug. + * 2. we will return an error message on exception in your Response rather + * than the default 404.html page. + */ +const DEBUG = false; + +export default { + async fetch(request, env, ctx) { + let options = { + ASSET_NAMESPACE: env.__STATIC_CONTENT, + ASSET_MANIFEST: assetManifest, + }; + + /** + * You can add custom logic to how we fetch your assets + * by configuring the function `mapRequestToAsset` + */ + // options.mapRequestToAsset = handlePrefix(/^\/docs/) + + try { + if (DEBUG) { + // customize caching + options.cacheControl = { + bypassCache: true, + }; + } + + const page = await getAssetFromKV( + { + request, + waitUntil(promise) { + return ctx.waitUntil(promise); + }, + }, + options + ); + + // allow headers to be altered + const response = new Response(page.body, page); + + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "unsafe-url"); + response.headers.set("Feature-Policy", "none"); + + return response; + } catch (e) { + // if an error is thrown try to serve the asset at 404.html + if (!DEBUG) { + try { + let notFoundResponse = await getAssetFromKV( + { + request, + waitUntil(promise) { + return ctx.waitUntil(promise); + }, + }, + { + ASSET_NAMESPACE: env.__STATIC_CONTENT, + ASSET_MANIFEST: assetManifest, + mapRequestToAsset: (req) => + new Request(`${new URL(req.url).origin}/404.html`, req), + } + ); + + return new Response(notFoundResponse.body, { + ...notFoundResponse, + status: 404, + }); + } catch (e) {} + } + + return new Response(e.message || e.toString(), { status: 500 }); + } + }, +}; diff --git a/fixtures/sites-app/src/service-worker.js b/fixtures/sites-app/src/service-worker.js new file mode 100644 index 0000000..9cff3ea --- /dev/null +++ b/fixtures/sites-app/src/service-worker.js @@ -0,0 +1,66 @@ +import { + getAssetFromKV, + mapRequestToAsset, +} from "@cloudflare/kv-asset-handler"; + +/** + * The DEBUG flag will do two things that help during development: + * 1. we will skip caching on the edge, which makes it easier to + * debug. + * 2. we will return an error message on exception in your Response rather + * than the default 404.html page. + */ +const DEBUG = false; + +addEventListener("fetch", (event) => { + event.respondWith(handleEvent(event)); +}); + +async function handleEvent(event) { + let options = {}; + + /** + * You can add custom logic to how we fetch your assets + * by configuring the function `mapRequestToAsset` + */ + // options.mapRequestToAsset = handlePrefix(/^\/docs/) + + try { + if (DEBUG) { + // customize caching + options.cacheControl = { + bypassCache: true, + }; + } + + const page = await getAssetFromKV(event, options); + + // allow headers to be altered + const response = new Response(page.body, page); + + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "unsafe-url"); + response.headers.set("Feature-Policy", "none"); + + return response; + } catch (e) { + // if an error is thrown try to serve the asset at 404.html + if (!DEBUG) { + try { + let notFoundResponse = await getAssetFromKV(event, { + mapRequestToAsset: (req) => + new Request(`${new URL(req.url).origin}/404.html`, req), + }); + + return new Response(notFoundResponse.body, { + ...notFoundResponse, + status: 404, + }); + } catch (e) {} + } + + return new Response(e.message || e.toString(), { status: 500 }); + } +} diff --git a/fixtures/sites-app/wrangler.jsonc b/fixtures/sites-app/wrangler.jsonc new file mode 100644 index 0000000..ea93578 --- /dev/null +++ b/fixtures/sites-app/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "sites-app", + "site": { + "bucket": "./public", + }, + "main": "src/modules.js", +} diff --git a/fixtures/start-worker-node-test/package.json b/fixtures/start-worker-node-test/package.json new file mode 100644 index 0000000..28c6e4d --- /dev/null +++ b/fixtures/start-worker-node-test/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/start-worker-node", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "scripts": { + "test:ci": "node --test --test-timeout=15000" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/is-even": "^1.0.2", + "get-port": "^7.1.0", + "is-even": "^1.0.0", + "miniflare": "workspace:*", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/start-worker-node-test/src/config-errors.test.js b/fixtures/start-worker-node-test/src/config-errors.test.js new file mode 100644 index 0000000..a1dcadc --- /dev/null +++ b/fixtures/start-worker-node-test/src/config-errors.test.js @@ -0,0 +1,102 @@ +import assert from "node:assert"; +import test, { describe } from "node:test"; +import getPort from "get-port"; +import { unstable_startWorker } from "wrangler"; + +describe("startWorker - configuration errors", () => { + test("providing an incorrect entrypoint to startWorker", async () => { + await assert.rejects( + unstable_startWorker({ + entrypoint: "not a real entrypoint", + }), + (err) => { + assert(err instanceof Error); + assert.strictEqual( + err.message, + "An error occurred when starting the server" + ); + const cause = err.cause; + assert(cause instanceof Error); + assert.match( + cause.message, + /The entry-point file at "not a real entrypoint" was not found\./ + ); + return true; + } + ); + }); + + test("providing a non existing config file to startWorker", async () => { + await assert.rejects( + unstable_startWorker({ config: "non-existing-config" }), + (err) => { + assert(err instanceof Error); + assert.strictEqual( + err.message, + "An error occurred when starting the server" + ); + const cause = err.cause; + assert(cause instanceof Error); + assert.match( + cause.message, + /Missing entry-point to Worker script or to assets directory/ + ); + return true; + } + ); + }); + + test("providing an incorrect config to setConfig", async () => { + const worker = await unstable_startWorker({ + config: "wrangler.json", + dev: { + persist: false, + server: { + port: await getPort(), + }, + inspector: false, + }, + }); + + await assert.rejects( + worker.setConfig({ config: "non-existing-config" }, true), + (err) => { + assert(err instanceof Error); + assert.match( + err.message, + /Missing entry-point to Worker script or to assets directory/ + ); + return true; + } + ); + + await worker.dispose(); + }); + + test("providing an incorrect entrypoint to setConfig", async () => { + const worker = await unstable_startWorker({ + config: "wrangler.json", + dev: { + persist: false, + server: { + port: await getPort(), + }, + inspector: false, + }, + }); + + await assert.rejects( + worker.setConfig({ entrypoint: "not a real entrypoint" }, true), + (err) => { + assert(err instanceof Error); + assert.match( + err.message, + /The entry-point file at "not a real entrypoint" was not found\./ + ); + return true; + } + ); + + await worker.dispose(); + }); +}); diff --git a/fixtures/start-worker-node-test/src/index.test.js b/fixtures/start-worker-node-test/src/index.test.js new file mode 100644 index 0000000..451c705 --- /dev/null +++ b/fixtures/start-worker-node-test/src/index.test.js @@ -0,0 +1,28 @@ +import assert from "node:assert"; +import test, { after, before, describe } from "node:test"; +import { unstable_startWorker } from "wrangler"; + +describe("worker", () => { + /** + * @type {Awaited>} + */ + let worker; + + before(async () => { + worker = await unstable_startWorker({ + config: "wrangler.json", + dev: { persist: false }, + }); + }); + + test("hello world", async () => { + assert.strictEqual( + await (await worker.fetch("http://example.com")).text(), + "Hello from even" + ); + }); + + after(async () => { + await worker.dispose(); + }); +}); diff --git a/fixtures/start-worker-node-test/src/index.ts b/fixtures/start-worker-node-test/src/index.ts new file mode 100644 index 0000000..ac53f66 --- /dev/null +++ b/fixtures/start-worker-node-test/src/index.ts @@ -0,0 +1,13 @@ +import isEven from "is-even"; +import { sayHello } from "./say-hello"; + +export default { + async fetch(request): Promise { + const url = new URL(request.url); + return new Response( + sayHello( + isEven(Number(url.searchParams.get("number") ?? "0")) ? "even" : "odd" + ) + ); + }, +} satisfies ExportedHandler; diff --git a/fixtures/start-worker-node-test/src/say-hello.ts b/fixtures/start-worker-node-test/src/say-hello.ts new file mode 100644 index 0000000..b83bb8b --- /dev/null +++ b/fixtures/start-worker-node-test/src/say-hello.ts @@ -0,0 +1,3 @@ +export function sayHello(url: string) { + return `Hello from ${url}`; +} diff --git a/fixtures/start-worker-node-test/tsconfig.json b/fixtures/start-worker-node-test/tsconfig.json new file mode 100644 index 0000000..3f9d8e2 --- /dev/null +++ b/fixtures/start-worker-node-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node", "@cloudflare/workers-types"], + "checkJs": true + }, + "include": ["**/*.js", "**/*.ts"] +} diff --git a/fixtures/start-worker-node-test/wrangler.json b/fixtures/start-worker-node-test/wrangler.json new file mode 100644 index 0000000..46ed31d --- /dev/null +++ b/fixtures/start-worker-node-test/wrangler.json @@ -0,0 +1,5 @@ +{ + "name": "build-miniflare", + "main": "src/index.ts", + "compatibility_date": "2025-01-16" +} diff --git a/fixtures/unbound-durable-object/package.json b/fixtures/unbound-durable-object/package.json new file mode 100644 index 0000000..e3b68ba --- /dev/null +++ b/fixtures/unbound-durable-object/package.json @@ -0,0 +1,15 @@ +{ + "name": "@fixture/unbound-durable-object", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/unbound-durable-object/src/index.ts b/fixtures/unbound-durable-object/src/index.ts new file mode 100644 index 0000000..b8b6164 --- /dev/null +++ b/fixtures/unbound-durable-object/src/index.ts @@ -0,0 +1,60 @@ +import { DurableObject } from "cloudflare:workers"; + +export class Counter extends DurableObject { + async getCounterValue() { + let value = (await this.ctx.storage.get("value")) || 0; + return value; + } + + async increment(amount = 1) { + let value = (await this.ctx.storage.get("value")) || 0; + value += amount; + await this.ctx.storage.put("value", value); + return value; + } + + async decrement(amount = 1) { + let value = (await this.ctx.storage.get("value")) || 0; + value -= amount; + await this.ctx.storage.put("value", value); + return value; + } +} + +export default { + async fetch( + request: Request, + _env: never, + ctx: ExecutionContext + ): Promise { + let url = new URL(request.url); + let name = url.searchParams.get("name"); + if (!name) { + return new Response( + "Select a Durable Object to contact by using" + + " the `name` URL query string parameter, for example, ?name=A" + ); + } + + let stub = ctx.exports.Counter.getByName(name); + + // Send a request to the Durable Object using RPC methods, then await its response. + let count = null; + switch (url.pathname) { + case "/increment": + count = await stub.increment(); + break; + case "/decrement": + count = await stub.decrement(); + break; + case "/": + // Serves the current value. + count = await stub.getCounterValue(); + break; + default: + return new Response("Not found", { status: 404 }); + } + + return new Response(`count: ${count}`); + }, +}; diff --git a/fixtures/unbound-durable-object/tests/index.test.ts b/fixtures/unbound-durable-object/tests/index.test.ts new file mode 100644 index 0000000..e47febd --- /dev/null +++ b/fixtures/unbound-durable-object/tests/index.test.ts @@ -0,0 +1,30 @@ +import { resolve } from "path"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +const basePath = resolve(__dirname, ".."); +const server = createTestHarness({ + root: basePath, + workers: [{ configPath: "wrangler.jsonc" }], +}); + +describe("Unbound DO is available through `ctx.exports`", () => { + beforeAll(async () => { + await server.listen(); + }); + + afterAll(async () => { + await server.close(); + }); + + it("can execute storage operations", async ({ expect }) => { + const doName = crypto.randomUUID(); + let response = await server.fetch(`/?name=${doName}`); + let content = await response.text(); + expect(content).toMatchInlineSnapshot(`"count: 0"`); + + response = await server.fetch(`/increment?name=${doName}`); + content = await response.text(); + expect(content).toMatchInlineSnapshot(`"count: 1"`); + }); +}); diff --git a/fixtures/unbound-durable-object/tests/tsconfig.json b/fixtures/unbound-durable-object/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/unbound-durable-object/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/unbound-durable-object/tsconfig.json b/fixtures/unbound-durable-object/tsconfig.json new file mode 100644 index 0000000..2431bac --- /dev/null +++ b/fixtures/unbound-durable-object/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "types": ["@cloudflare/workers-types/experimental"], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/unbound-durable-object/vitest.config.mts b/fixtures/unbound-durable-object/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/unbound-durable-object/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/unbound-durable-object/wrangler.jsonc b/fixtures/unbound-durable-object/wrangler.jsonc new file mode 100644 index 0000000..e4f9f04 --- /dev/null +++ b/fixtures/unbound-durable-object/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "name": "unbound-durable-object", + "main": "src/index.ts", + "compatibility_date": "2025-01-01", + "compatibility_flags": ["enable_ctx_exports"], + // This DO intentionally doesn't have a binding + // "durable_objects": { + // "bindings": [ + // { + // "name": "COUNTER", + // "class_name": "Counter", + // }, + // ], + // }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["Counter"], + }, + ], +} diff --git a/fixtures/unsafe-external-plugin/README.md b/fixtures/unsafe-external-plugin/README.md new file mode 100644 index 0000000..e9ded4e --- /dev/null +++ b/fixtures/unsafe-external-plugin/README.md @@ -0,0 +1,3 @@ +# Unsafe External Plugin + +This folder contains an example of an Unsafe External Miniflare Plugin that extends Miniflare's suite of plugins with local development simulators. An example of usage can be found in the [worker-with-unsafe-exteral-plugin fixture](../worker-with-unsafe-external-plugin/). diff --git a/fixtures/unsafe-external-plugin/package.json b/fixtures/unsafe-external-plugin/package.json new file mode 100644 index 0000000..dbaccda --- /dev/null +++ b/fixtures/unsafe-external-plugin/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/unsafe-external-plugin", + "private": true, + "description": "An example of an unsafe external plugin for Miniflare", + "license": "ISC", + "author": "", + "main": "dist/index.js", + "scripts": { + "build": "tsx tools/build.ts" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "esbuild": "catalog:default", + "miniflare": "workspace:*", + "tsx": "^3.12.8", + "zod": "3.22.3" + } +} diff --git a/fixtures/unsafe-external-plugin/src/index.ts b/fixtures/unsafe-external-plugin/src/index.ts new file mode 100644 index 0000000..75d1444 --- /dev/null +++ b/fixtures/unsafe-external-plugin/src/index.ts @@ -0,0 +1,8 @@ +import { + UNSAFE_PLUGIN_NAME, + UNSAFE_SERVICE_PLUGIN, +} from "./plugins/unsafe-service"; + +export const plugins = { + [UNSAFE_PLUGIN_NAME]: UNSAFE_SERVICE_PLUGIN, +}; diff --git a/fixtures/unsafe-external-plugin/src/plugins/unsafe-service.ts b/fixtures/unsafe-external-plugin/src/plugins/unsafe-service.ts new file mode 100644 index 0000000..1218e9a --- /dev/null +++ b/fixtures/unsafe-external-plugin/src/plugins/unsafe-service.ts @@ -0,0 +1,149 @@ +import fs from "node:fs/promises"; +import { + getMiniflareObjectBindings, + getPersistPath, + Plugin, + ProxyNodeBinding, + SERVICE_LOOPBACK, + SharedBindings, +} from "miniflare"; +// The below imports (prefixed with `worker:`) +// will be converted by our ESBuild plugin +// into functions that load the transpiled Workers as JS +import BINDING_WORKER from "worker:binding.worker"; +import OBJECT_WORKER from "worker:object.worker"; +import { z } from "zod"; +import type { Service, Worker_Binding } from "miniflare"; + +export const UNSAFE_PLUGIN_NAME = "unsafe-plugin"; + +export const UnsafeServiceBindingOptionSchema = z + .array( + z.object({ + name: z.string(), + type: z.string(), + plugin: z.object({ + package: z.string(), + name: z.string(), + }), + options: z.object({ emitLogs: z.boolean() }), + }) + ) + .or(z.undefined()); + +export const UNSAFE_SERVICE_PLUGIN: Plugin< + typeof UnsafeServiceBindingOptionSchema +> = { + options: UnsafeServiceBindingOptionSchema, + /** + * getBindings will add bindings to the user's Workers. Specifically, we add a binding to a service + * that will expose an `UnsafeBindingServiceEntrypoint` + * @param options - A map of bindings names to options provided for that binding. + * @returns + */ + async getBindings(options) { + return options?.map((binding) => { + return { + name: binding.name, + service: { + name: `${UNSAFE_PLUGIN_NAME}:${binding.name}`, + entrypoint: "UnsafeBindingServiceEntrypoint", + }, + }; + }); + }, + getNodeBindings(options) { + return Object.fromEntries( + options?.map((binding) => [binding.name, new ProxyNodeBinding()]) ?? [] + ); + }, + async getServices({ + options, + tmpPath, + defaultPersistRoot, + unsafeStickyBlobs, + }) { + if (!options || options.length === 0) { + return []; + } + + const persistPath = getPersistPath( + UNSAFE_PLUGIN_NAME, + tmpPath, + defaultPersistRoot, + undefined + ); + + await fs.mkdir(persistPath, { recursive: true }); + + // Create a service that will persist any data + const storageService = { + name: `${UNSAFE_PLUGIN_NAME}:storage`, + disk: { path: persistPath, writable: true }, + } satisfies Service; + + const objectService = { + name: `${UNSAFE_PLUGIN_NAME}:object`, + worker: { + compatibilityDate: "2025-01-01", + modules: [ + { + name: "object.worker.js", + esModule: OBJECT_WORKER(), + }, + ], + durableObjectNamespaces: [ + { + className: "UnsafeBindingObject", + uniqueKey: `miniflare-unsafe-binding-UnsafeBindingObject`, + }, + ], + // Store Durable Object SQL databases in persist path + durableObjectStorage: { localDisk: storageService.name }, + // Bind blob disk directory service to object + bindings: [ + { + name: SharedBindings.MAYBE_SERVICE_BLOBS, + service: { name: storageService.name }, + }, + { + name: SharedBindings.MAYBE_SERVICE_LOOPBACK, + service: { name: SERVICE_LOOPBACK }, + }, + ...getMiniflareObjectBindings(unsafeStickyBlobs), + ], + }, + } satisfies Service; + + const bindingWorker = options.map( + (binding) => + ({ + name: `${UNSAFE_PLUGIN_NAME}:${binding.name}`, + worker: { + compatibilityDate: "2025-01-01", + modules: [ + { + name: "binding.worker.js", + esModule: BINDING_WORKER(), + }, + ], + bindings: [ + { + name: "config", + json: JSON.stringify(binding.options), + }, + { + name: "store", + durableObjectNamespace: { + className: "UnsafeBindingObject", + serviceName: objectService.name, + }, + }, + ], + }, + }) satisfies Service + ); + + return [...bindingWorker, storageService, objectService]; + }, +}; diff --git a/fixtures/unsafe-external-plugin/src/plugins/worker-shim.ts b/fixtures/unsafe-external-plugin/src/plugins/worker-shim.ts new file mode 100644 index 0000000..a3f08ac --- /dev/null +++ b/fixtures/unsafe-external-plugin/src/plugins/worker-shim.ts @@ -0,0 +1,7 @@ +/** + * ESBuild will build the Workers from '../workers' and provide the built script + * files as variables on the global scope. + */ +declare module "worker:*" { + export default function (): string; +} diff --git a/fixtures/unsafe-external-plugin/src/workers/binding.worker.ts b/fixtures/unsafe-external-plugin/src/workers/binding.worker.ts new file mode 100644 index 0000000..df3db25 --- /dev/null +++ b/fixtures/unsafe-external-plugin/src/workers/binding.worker.ts @@ -0,0 +1,61 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import type { UnsafeBindingObject } from "./object.worker"; + +// ENV configuration +interface Env { + config: { emitLogs: boolean }; + store: DurableObjectNamespace; +} + +/** + * UnsafeBinding offers two RPCs, `performUnsafeWrite` and `performUnsafeRead`. + */ +export class UnsafeBindingServiceEntrypoint extends WorkerEntrypoint { + override async fetch(_request: Request): Promise { + return new Response("This is a development stub for an unsafe worker.", { + status: 200, + statusText: "OK", + headers: { + "content-type": "text/plain", + }, + }); + } + + async performUnsafeWrite(key: string, value: number) { + if (this.env.config.emitLogs) { + console.log("Emitting a log for write operation"); + } + const objectNamespace = this.env.store; + const namespaceId = JSON.stringify(this.env.config); + const id = objectNamespace.idFromName(namespaceId); + const stub = objectNamespace.get(id); + await stub.set(key, value); + + return { + ok: true, + result: `Set key ${key} to ${value}`, + meta: { + workersVersion: "my-version-from-dev", + }, + }; + } + + async performUnsafeRead(key: string) { + if (this.env.config.emitLogs) { + console.log("Emitting a log for read operation"); + } + const objectNamespace = this.env.store; + const namespaceId = JSON.stringify(this.env.config); + const id = objectNamespace.idFromName(namespaceId); + const stub = objectNamespace.get(id); + const value = await stub.get(key); + + return { + ok: true, + result: value, + meta: { + workersVersion: "my-version-from-dev", + }, + }; + } +} diff --git a/fixtures/unsafe-external-plugin/src/workers/object.worker.ts b/fixtures/unsafe-external-plugin/src/workers/object.worker.ts new file mode 100644 index 0000000..13e179e --- /dev/null +++ b/fixtures/unsafe-external-plugin/src/workers/object.worker.ts @@ -0,0 +1,11 @@ +import { DurableObject } from "cloudflare:workers"; + +export class UnsafeBindingObject extends DurableObject { + async get(tag: string) { + return await this.ctx.storage.get(tag); + } + + async set(key: string, value: number) { + await this.ctx.storage.put(key, value); + } +} diff --git a/fixtures/unsafe-external-plugin/tools/build.ts b/fixtures/unsafe-external-plugin/tools/build.ts new file mode 100644 index 0000000..095c36b --- /dev/null +++ b/fixtures/unsafe-external-plugin/tools/build.ts @@ -0,0 +1,110 @@ +import { join, resolve } from "node:path"; +import { + BuildContext, + BuildOptions, + context, + Plugin, + build as runBuild, +} from "esbuild"; + +type EmbedWorkersOptions = { + /** + * workersRootDir is a path to a directory containing external Workers that + * will be bundled + */ + workersRootDir: string; + workerOutputDir: string; +}; + +/** + * embedWorkerPlugin is an ESBuild plugin that will look for imports to Workers specified by `worker:` + * and bundle them into the final output. + */ +export const embedWorkersPlugin: (options: EmbedWorkersOptions) => Plugin = ({ + workerOutputDir, + workersRootDir, +}) => { + return { + name: "embed-workers", + setup(build) { + const namespace = "embed-worker"; + // For imports prefixed with `worker:`, attempt to resolve them from a directory containing + // your Workers + build.onResolve({ filter: /^worker:/ }, async (args) => { + let name = args.path.substring("worker:".length); + // Allow `.worker` to be omitted + if (!name.endsWith(".worker")) name += ".worker"; + // Use `build.resolve()` API so Workers can be written as `m?[jt]s` files + const result = await build.resolve("./" + name, { + kind: "import-statement", + // Resolve relative to the directory containing the Workers + resolveDir: workersRootDir, + }); + if (result.errors.length > 0) return { errors: result.errors }; + return { path: result.path, namespace }; + }); + build.onLoad({ filter: /.*/, namespace }, async (args) => { + await runBuild({ + platform: "node", // Marks `node:*` imports as external + format: "esm", + target: "esnext", + bundle: true, + sourcemap: true, + sourcesContent: false, + external: ["cloudflare:workers"], + entryPoints: [args.path], + minifySyntax: true, + outdir: workerOutputDir, + plugins: [], + }); + + let outPath = args.path.substring(workersRootDir.length + 1); + outPath = outPath.substring(0, outPath.lastIndexOf(".")) + ".js"; + outPath = JSON.stringify(outPath); + const contents = ` + import fs from "fs"; + import path from "path"; + import url from "url"; + let contents; + export default function() { + if (contents !== undefined) return contents; + const filePath = path.join(__dirname, "workers", ${outPath}); + contents = fs.readFileSync(filePath, "utf8") + "//# sourceURL=" + url.pathToFileURL(filePath); + return contents; + } + `; + return { contents, loader: "js" }; + }); + }, + }; +}; + +const distDir = resolve(__dirname, "../dist"); +// When outputting the Worker, map to the structure of 'src'. +// This means the plugin will outout the build Workers to a `workers` dist in `dir` +const workerOutputDir = resolve(distDir, "workers"); +const workersDir = resolve(__dirname, "../src/workers"); + +export async function buildPackage() { + console.log("Building the module"); + await runBuild({ + platform: "node", + target: "esnext", + format: "cjs", + bundle: true, + sourcemap: true, + entryPoints: ["src/index.ts"], + plugins: [ + embedWorkersPlugin({ + workersRootDir: workersDir, + workerOutputDir, + }), + ], + external: ["@cloudflare/workers-types", "miniflare"], + outdir: distDir, + }); +} + +buildPackage().catch((exc) => { + console.error("Failed to build external package", `${exc}`); +}); diff --git a/fixtures/unsafe-external-plugin/tsconfig.json b/fixtures/unsafe-external-plugin/tsconfig.json new file mode 100644 index 0000000..35cdf79 --- /dev/null +++ b/fixtures/unsafe-external-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "types": ["@cloudflare/workers-types/experimental"], + "noEmit": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/unsafe-external-plugin/turbo.json b/fixtures/unsafe-external-plugin/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/unsafe-external-plugin/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/vitest-pool-workers-examples/README.md b/fixtures/vitest-pool-workers-examples/README.md new file mode 100644 index 0000000..d883b88 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/README.md @@ -0,0 +1,26 @@ +# `@cloudflare/vitest-pool-workers` Examples + +This directory contains example projects tested with `@cloudflare/vitest-pool-workers`. It aims to provide the building blocks for you to write tests for your own Workers. + +| Directory | Overview | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [✅ basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `exports.default` | +| [⚠️ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] | +| [⚡️ pages-functions-unit-integration-self](pages-functions-unit-integration-self) | Functions unit tests and integration tests using `exports.default` | +| [📦 kv-r2-caches](kv-r2-caches) | Tests using KV, R2 and the Cache API | +| [📚 d1](d1) | Tests using D1 with migrations | +| [📌 durable-objects](durable-objects) | Tests using Durable Objects with direct access, alarms and eviction | +| [🔁 workflows](workflows) | Tests using Workflows | +| [🚥 queues](queues) | Tests using Queue producers and consumers | +| [🚰 pipelines](pipelines) | Tests using Pipelines | +| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server | +| [🤹 request-mocking](request-mocking) | Tests using declarative (MSW) / imperative outbound request mocks | +| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks | +| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules | +| [🤯 rpc](rpc) | Tests using named entrypoints, Durable Objects and RPC | +| [🧠 ai-vectorize](ai-vectorize) | Tests using Workers AI and Vectorize | +| [🔄 context-exports](context-exports) | Tests using context exports | +| [📥 dynamic-import](dynamic-import) | Tests using dynamic imports | +| [🖼️ images](images) | Tests using the Images binding | + +[^1]: When using `exports.default` for integration tests, your worker code runs in the same context as the test runner. This means you can use global mocks to control your worker, but also means your worker uses the same subtly different module resolution behaviour provided by Vite. Usually this isn't a problem, but if you'd like to run your worker in a fresh environment that's as close to production as possible, using an auxiliary worker may be a good idea. Note this prevents global mocks from controlling your worker, and requires you to build your worker ahead-of-time. This means your tests won't re-run automatically if you change your worker's source code, but could be useful if you have a complicated build process (e.g. full-stack framework). diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/README.md b/fixtures/vitest-pool-workers-examples/ai-vectorize/README.md new file mode 100644 index 0000000..e8d3ae2 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/README.md @@ -0,0 +1,7 @@ +# 🤖 AI and Vectorize + +This Worker uses the AI and Vectorize bindings. @cloudflare/vitest-pool-workers@^0.8.1 is required to use AI and Vectorize bindings in the Vitest integration. + +[!WARNING] + +Because Workers AI and Vectorize bindings do not have a local simulator, usage of these bindings will always access your Cloudflare account, and so will incur usage charges even in local development and testing. We recommend mocking any usage of these bindings in your tests as demonstrated in this fixture. diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/global-setup.ts b/fixtures/vitest-pool-workers-examples/ai-vectorize/global-setup.ts new file mode 100644 index 0000000..4f7d9cb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/global-setup.ts @@ -0,0 +1,9 @@ +import childProcess from "node:child_process"; + +// Global setup runs inside Node.js, not `workerd` +export default function () { + const label = "Built ai-vectorize Worker"; + console.time(label); + childProcess.execSync("wrangler build", { cwd: __dirname }); + console.timeEnd(label); +} diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/src/env.d.ts b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/env.d.ts new file mode 100644 index 0000000..e0d7259 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./ai-vectorize/src/env.d.ts -c ./ai-vectorize/wrangler.jsonc --no-include-runtime` (hash: 54950e4bb5477ca70c1306130c0748c4) +interface __BaseEnv_Env { + VECTORIZE: VectorizeIndex; + AI: Ai; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/src/index.ts b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/index.ts new file mode 100644 index 0000000..d693e06 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/index.ts @@ -0,0 +1,31 @@ +export default { + async fetch(request, env, ctx): Promise { + const path = new URL(request.url).pathname; + + if (path === "/ai") { + const stories = [ + "This is a story about an orange cloud", + "This is a story about a llama", + ]; + const modelResp: Ai_Cf_Baai_Bge_Base_En_V1_5_Output = await env.AI.run( + "@cf/baai/bge-base-en-v1.5", + { + text: stories, + } + ); + return Response.json(modelResp); + } + + if (path === "/vectorize") { + const vectors: VectorizeVector[] = [ + { id: "123", values: [...Array(768).keys()] }, + { id: "456", values: [...Array(768).keys()] }, + ]; + + let inserted = await env.VECTORIZE.upsert(vectors); + return Response.json(inserted); + } + + return new Response("Hello World!"); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/test/index.spec.ts b/fixtures/vitest-pool-workers-examples/ai-vectorize/test/index.spec.ts new file mode 100644 index 0000000..ae22f4c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/test/index.spec.ts @@ -0,0 +1,80 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { describe, it, vi } from "vitest"; +import worker from "../src/index"; + +// For now, you'll need to do something like this to get a correctly-typed +// `Request` to pass to `worker.fetch()`. +const IncomingRequest = Request; + +describe("Tests that do hit the AI binding", () => { + describe("unit style", () => { + it("lets you mock the ai binding", async ({ expect }) => { + const request = new IncomingRequest("http://example.com/ai"); + + const ctx = createExecutionContext(); + + // mock the AI run function by directly modifying `env` + vi.spyOn(env.AI, "run").mockResolvedValue({ + shape: [1, 2], + data: [[0, 0]], + }); + const response = await worker.fetch(request, env, ctx); + + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot( + `"{"shape":[1,2],"data":[[0,0]]}"` + ); + }); + + it("lets you mock the vectorize binding", async ({ expect }) => { + const request = new IncomingRequest("http://example.com/vectorize"); + const ctx = createExecutionContext(); + + // mock the vectorize upsert function by directly modifying `env` + const mockVectorizeStore: VectorizeVector[] = []; + + vi.spyOn(env.VECTORIZE, "upsert").mockImplementation(async (vectors) => { + mockVectorizeStore.push(...vectors); + return { + mutationId: "123", + count: vectors.length, + ids: vectors.map((v) => v.id), + }; + }); + + const response = await worker.fetch(request, env, ctx); + + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot( + `"{"mutationId":"123","count":2,"ids":["123","456"]}"` + ); + expect(mockVectorizeStore.map((v) => v.id)).toMatchInlineSnapshot(` + [ + "123", + "456", + ] + `); + }); + }); +}); + +describe("Tests that do not hit the AI binding", () => { + it("responds with Hello World! (unit style)", async ({ expect }) => { + const request = new IncomingRequest("http://example.com"); + // Create an empty context to pass to `worker.fetch()`. + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + }); + + it("responds with Hello World! (integration style)", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com"); + expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/ai-vectorize/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/tsconfig.json b/fixtures/vitest-pool-workers-examples/ai-vectorize/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/vitest.config.ts b/fixtures/vitest-pool-workers-examples/ai-vectorize/vitest.config.ts new file mode 100644 index 0000000..1215075 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/vitest.config.ts @@ -0,0 +1,18 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + remoteBindings: false, + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + test: { + globalSetup: ["./global-setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/ai-vectorize/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/ai-vectorize/wrangler.jsonc new file mode 100644 index 0000000..e710172 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/ai-vectorize/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "name": "ai-vectorize", + "main": "src/index.ts", + // compatibility_date is required so we can run `wrangler build` + "compatibility_date": "2025-03-20", + "ai": { + "binding": "AI", + }, + "vectorize": [ + { + "binding": "VECTORIZE", + "index_name": "embeddings", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/README.md b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/README.md new file mode 100644 index 0000000..294fc96 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/README.md @@ -0,0 +1,8 @@ +# ⚠️ basics-integration-auxiliary + +This Worker contains basic `fetch` and `scheduled` handlers. Instead of running the Worker-under-test in the same Worker as the test runner, this example defines the Worker-under-test as an _auxiliary_ Worker. This means the Worker runs in a separate isolate to the test runner, with a different global scope. The Worker-under-test runs in an environment closer to production, but Vite transformations and hot-module-reloading aren't applied to the Worker—you must compile your TypeScript to JavaScript beforehand. This is done in [global-setup.ts](global-setup.ts). Note auxiliary workers cannot be configured from `wrangler.toml` files—you must use Miniflare `WorkerOptions`. + +| Test | Overview | +| ----------------------------------------------------------------------------------------- | ---------------------------------- | +| [fetch-integration-auxiliary.test.ts](test/fetch-integration-auxiliary.test.ts) | Basic `fetch` integration test | +| [scheduled-integration-auxiliary.test.ts](test%2Fscheduled-integration-auxiliary.test.ts) | Basic `scheduled` integration test | diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/global-setup.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/global-setup.ts new file mode 100644 index 0000000..a0cbf0e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/global-setup.ts @@ -0,0 +1,9 @@ +import childProcess from "node:child_process"; + +// Global setup runs inside Node.js, not `workerd` +export default function () { + const label = "Built basics-integration-auxiliary worker"; + console.time(label); + childProcess.execSync("wrangler build", { cwd: __dirname }); + console.timeEnd(label); +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/index.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/index.ts new file mode 100644 index 0000000..bcd8d09 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/index.ts @@ -0,0 +1,8 @@ +export default { + async fetch(request, env, ctx) { + return new Response("👋"); + }, + async scheduled(controller, env, ctx) { + // ... + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/env.d.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/env.d.ts new file mode 100644 index 0000000..8792875 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/env.d.ts @@ -0,0 +1,5 @@ +declare namespace Cloudflare { + interface Env { + WORKER: Fetcher; + } +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/fetch-integration-auxiliary.test.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/fetch-integration-auxiliary.test.ts new file mode 100644 index 0000000..d3f0d87 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/fetch-integration-auxiliary.test.ts @@ -0,0 +1,7 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches fetch event", async ({ expect }) => { + const response = await env.WORKER.fetch("http://example.com"); + expect(await response.text()).toBe("👋"); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/scheduled-integration-auxiliary.test.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/scheduled-integration-auxiliary.test.ts new file mode 100644 index 0000000..8d7de82 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/scheduled-integration-auxiliary.test.ts @@ -0,0 +1,12 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches scheduled event", async ({ expect }) => { + // Note the `Fetcher#scheduled()` method is experimental, and requires the + // `service_binding_extra_handlers` compatibility flag to be enabled. + const result = await env.WORKER.scheduled({ + scheduledTime: new Date(1000), + cron: "30 * * * *", + }); + expect(result.outcome).toBe("ok"); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/tsconfig.json new file mode 100644 index 0000000..40d2455 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/vitest.config.ts b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/vitest.config.ts new file mode 100644 index 0000000..3631a5b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/vitest.config.ts @@ -0,0 +1,46 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + // Configuration for the test runner Worker + compatibilityDate: "2024-01-01", + compatibilityFlags: [ + // This illustrates a Worker that in production only wants v1 of Node.js compatibility. + // The Vitest pool integration will need to remove this flag since the `MockAgent` requires v2. + "no_nodejs_compat_v2", + "nodejs_compat", + // Required to use `WORKER.scheduled()`. This is an experimental + // compatibility flag, and cannot be enabled in production. + "service_binding_extra_handlers", + ], + serviceBindings: { + WORKER: "worker-under-test", + }, + + workers: [ + // Configuration for the "auxiliary" Worker under test. + // Unfortunately, auxiliary Workers cannot load their configuration + // from `wrangler.toml` files, and must be configured with Miniflare + // `WorkerOptions`. + { + name: "worker-under-test", + modules: true, + scriptPath: "./dist/index.js", // Built by `global-setup.ts` + compatibilityDate: "2024-01-01", + compatibilityFlags: ["nodejs_compat"], + }, + ], + }, + }), + ], + test: { + globalSetup: ["./global-setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/wrangler.jsonc new file mode 100644 index 0000000..2631c09 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "basics-integration-auxiliary", + "main": "src/index.ts", + // compatibility_date is required so we can run `wrangler build` + "compatibility_date": "2024-01-01", +} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/README.md b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/README.md new file mode 100644 index 0000000..2035e31 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/README.md @@ -0,0 +1,10 @@ +# ✅ basics-unit-integration-self + +This Worker contains basic `fetch` and `scheduled` handlers. Integration tests dispatch events using `exports` from the `cloudflare:workers` module. Unit tests call handler functions directly. + +| Test | Overview | +| ----------------------------------------------------------------------------- | ----------------------------------------------------- | +| [fetch-integration-self.test.ts](test/fetch-integration-self.test.ts) | Basic `fetch` integration test using `exports` | +| [fetch-unit.test.ts](test/fetch-unit.test.ts) | Basic unit test calling `worker.fetch()` directly | +| [scheduled-integration-self.test.ts](test/scheduled-integration-self.test.ts) | Basic `scheduled` integration test using `exports` | +| [scheduled-unit.test.ts](test/scheduled-unit.test.ts) | Basic unit test calling `worker.scheduled()` directly | diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/env.d.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/env.d.ts new file mode 100644 index 0000000..cb439c0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/env.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./basics-unit-integration-self/src/env.d.ts -c ./basics-unit-integration-self/wrangler.jsonc --no-include-runtime` (hash: 0c8cd5ae12176ebab8337cbfef98b516) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/greet.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/greet.ts new file mode 100644 index 0000000..9c92f3b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/greet.ts @@ -0,0 +1,3 @@ +export function greet(request: Request): string { + return `👋 ${request.url}`; +} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/index.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/index.ts new file mode 100644 index 0000000..1d87415 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/index.ts @@ -0,0 +1,13 @@ +import { greet } from "./greet"; + +export default { + async fetch(request, env, ctx) { + return new Response(greet(request)); + }, + async scheduled(controller, env, ctx) { + // ... + }, +} satisfies ExportedHandler; +// ^ Using `satisfies` provides type checking/completions for `ExportedHandler` +// whilst still allowing us to call `worker.fetch()` in tests without +// asserting `worker.fetch` is defined. diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-integration-self.test.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-integration-self.test.ts new file mode 100644 index 0000000..45bd1a0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-integration-self.test.ts @@ -0,0 +1,11 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches fetch event", async ({ expect }) => { + // `exports.default` here points to the worker running in the current isolate. + // This gets its handler from the `main` option in `vitest.config.mts`. + // Importantly, it uses the exact `import("../src").default` instance we could + // import in this file as its handler. + const response = await exports.default.fetch("http://example.com"); + expect(await response.text()).toBe("👋 http://example.com/"); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-unit.test.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-unit.test.ts new file mode 100644 index 0000000..75fda07 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/fetch-unit.test.ts @@ -0,0 +1,26 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import { greet } from "../src/greet"; +import worker from "../src/index"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `worker.fetch()`. +const IncomingRequest = Request; + +it("dispatches fetch event", async ({ expect }) => { + const request = new IncomingRequest("http://example.com"); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe("👋 http://example.com/"); +}); + +it("calls arbitrary function", ({ expect }) => { + const request = new Request("http://example.com"); + expect(greet(request)).toBe("👋 http://example.com/"); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-integration-self.test.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-integration-self.test.ts new file mode 100644 index 0000000..ed7c197 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-integration-self.test.ts @@ -0,0 +1,16 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches scheduled event", async ({ expect }) => { + // `exports.default` here points to the worker running in the current isolate. + // This gets its handler from the `main` option in `vitest.config.mts`. + // Importantly, it uses the exact `import("../src").default` instance we could + // import in this file as its handler. Note the `exports.default.scheduled()` method + // is experimental, and requires the `service_binding_extra_handlers` + // compatibility flag to be enabled. + const result = await exports.default.scheduled({ + scheduledTime: new Date(1000), + cron: "30 * * * *", + }); + expect(result.outcome).toBe("ok"); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-unit.test.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-unit.test.ts new file mode 100644 index 0000000..af0ea7c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/scheduled-unit.test.ts @@ -0,0 +1,18 @@ +import { + createExecutionContext, + createScheduledController, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import worker from "../src/index"; + +it("dispatches scheduled event", async () => { + const controller = createScheduledController({ + scheduledTime: new Date(1000), + cron: "30 * * * *", + }); + const ctx = createExecutionContext(); + await worker.scheduled(controller, env, ctx); + await waitOnExecutionContext(ctx); +}); diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/tsconfig.json b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/vitest.config.ts b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/vitest.config.ts new file mode 100644 index 0000000..3d82d8a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/vitest.config.ts @@ -0,0 +1,21 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + // Required to use `exports.default.scheduled()`. This is an experimental + // compatibility flag, and cannot be enabled in production. + compatibilityFlags: ["service_binding_extra_handlers"], + }, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/wrangler.jsonc new file mode 100644 index 0000000..7edd213 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/basics-unit-integration-self/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "basics-unit-integration-self", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one +} diff --git a/fixtures/vitest-pool-workers-examples/container-app/Dockerfile b/fixtures/vitest-pool-workers-examples/container-app/Dockerfile new file mode 100644 index 0000000..4666db8 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine + +WORKDIR /usr/src/app +RUN echo '{"name": "simple-node-app", "version": "1.0.0", "dependencies": {"ws": "^8.0.0"}}' > package.json +RUN npm install + +COPY ./container/simple-node-app.js app.js +EXPOSE 8787 +CMD ["node", "app.js"] diff --git a/fixtures/vitest-pool-workers-examples/container-app/README.md b/fixtures/vitest-pool-workers-examples/container-app/README.md new file mode 100644 index 0000000..f0ad4a9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/README.md @@ -0,0 +1,3 @@ +# Apps with no-op containers + +Currently, `vitest-pool-workers` does not support testing containers yet. It should still let you test your application as long as you do not test with any code paths that interact with containers. diff --git a/fixtures/vitest-pool-workers-examples/container-app/container/simple-node-app.js b/fixtures/vitest-pool-workers-examples/container-app/container/simple-node-app.js new file mode 100644 index 0000000..4579b16 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/container/simple-node-app.js @@ -0,0 +1,12 @@ +const { createServer } = require("http"); + +// Create HTTP server +const server = createServer(function (req, res) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("Hello World! Have an env var! " + process.env.MESSAGE); + res.end(); +}); + +server.listen(8787, function () { + console.log("Server listening on port 8080"); +}); diff --git a/fixtures/vitest-pool-workers-examples/container-app/src/env.d.ts b/fixtures/vitest-pool-workers-examples/container-app/src/env.d.ts new file mode 100644 index 0000000..3866e24 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./container-app/src/env.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime` (hash: 45be8189fa807ec0c802f13cfd9b1a25) +interface __BaseEnv_Env { + MY_CONTAINER: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "MyContainer"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/container-app/src/index.ts b/fixtures/vitest-pool-workers-examples/container-app/src/index.ts new file mode 100644 index 0000000..ff5125f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/src/index.ts @@ -0,0 +1,57 @@ +import { Container, getContainer, getRandom } from "@cloudflare/containers"; // in a real Worker + +export class MyContainer extends Container { + defaultPort = 8787; // The default port for the container to listen on + sleepAfter = "3m"; // Sleep the container if no requests are made in this timeframe + + envVars = { + MESSAGE: "I was passed in via the container class!", + }; + + override onStart() { + console.log("Container successfully started"); + } + + override onStop() { + console.log("Container successfully shut down"); + } + + override onError(error: unknown) { + console.log("Container error:", error); + } +} + +export default { + async fetch( + request: Request, + env: { MY_CONTAINER: DurableObjectNamespace } + ): Promise { + const pathname = new URL(request.url).pathname; + // If you want to route requests to a specific container, + // pass a unique container identifier to .get() + + if (pathname.startsWith("/container")) { + const containerInstance = getContainer(env.MY_CONTAINER, pathname); + return containerInstance.fetch(request); + } + + if (pathname.startsWith("/error")) { + const containerInstance = getContainer(env.MY_CONTAINER, "error-test"); + return containerInstance.fetch(request); + } + + if (pathname.startsWith("/lb")) { + const containerInstance = await getRandom(env.MY_CONTAINER, 3); + return containerInstance.fetch(request); + } + + if (pathname.startsWith("/singleton")) { + // getContainer will return a specific instance if no second argument is provided + return getContainer(env.MY_CONTAINER).fetch(request); + } + + return new Response( + "Call /container to start a container with a 10s timeout.\nCall /error to start a container that errors\nCall /lb to test load balancing" + ); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/container-app/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/container-app/src/tsconfig.json new file mode 100644 index 0000000..3a4e18a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.workerd.json", + "compilerOptions": { + // @cloudflare/containers@0.0.25 has an alarm() signature incompatible + // with the updated DurableObject base type in @cloudflare/workers-types. + // Remove when https://github.com/cloudflare/containers/issues/167 is resolved. + "skipLibCheck": true + }, + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/container-app/test/container.test.ts b/fixtures/vitest-pool-workers-examples/container-app/test/container.test.ts new file mode 100644 index 0000000..3c034c5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/test/container.test.ts @@ -0,0 +1,16 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches fetch event", { timeout: 10_000 }, async ({ expect }) => { + // requests to code paths that do not interact with a container should work fine + const res = await exports.default.fetch("http://example.com/"); + expect(await res.text()).toMatchInlineSnapshot(` + "Call /container to start a container with a 10s timeout. + Call /error to start a container that errors + Call /lb to test load balancing" + `); + // however if you attempt to start a container, you should expect an error + await expect( + exports.default.fetch("http://example.com/container/hello") + ).rejects.toThrow(); +}); diff --git a/fixtures/vitest-pool-workers-examples/container-app/test/container.unit.test.ts b/fixtures/vitest-pool-workers-examples/container-app/test/container.unit.test.ts new file mode 100644 index 0000000..5173f97 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/test/container.unit.test.ts @@ -0,0 +1,9 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +it("dispatches fetch event", { timeout: 10000 }, async ({ expect }) => { + const id = env.MY_CONTAINER.idFromName("helloagain"); + const stub = env.MY_CONTAINER.get(id); + // the DO constructor will now throw + await expect(() => stub.fetch("http://example.com/")).rejects.toThrow(); +}); diff --git a/fixtures/vitest-pool-workers-examples/container-app/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/container-app/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/container-app/tsconfig.json b/fixtures/vitest-pool-workers-examples/container-app/tsconfig.json new file mode 100644 index 0000000..7d4e80e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.node.json", + "compilerOptions": { + // @cloudflare/containers@0.0.25 has an alarm() signature incompatible + // with the updated DurableObject base type in @cloudflare/workers-types. + // Remove when https://github.com/cloudflare/containers/issues/167 is resolved. + "skipLibCheck": true + }, + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/container-app/vitest.config.ts b/fixtures/vitest-pool-workers-examples/container-app/vitest.config.ts new file mode 100644 index 0000000..d9a889a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/container-app/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/container-app/wrangler.jsonc new file mode 100644 index 0000000..ba12103 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/container-app/wrangler.jsonc @@ -0,0 +1,27 @@ +{ + "name": "container-app", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "containers": [ + { + "image": "./Dockerfile", + "class_name": "MyContainer", + "name": "container", + "max_instances": 2, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyContainer", + "name": "MY_CONTAINER", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyContainer"], + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/README.md b/fixtures/vitest-pool-workers-examples/context-exports/README.md new file mode 100644 index 0000000..5c29d61 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/README.md @@ -0,0 +1,9 @@ +# Context exports + +The Workers here demonstrate how to access and test the `ctx.exports` property. + +| Test | Overview | +| ----------------------------------------------- | ----------------------------------------------------------------------- | +| [auxiliary.test.ts](test/auxiliary.test.ts) | Integration test with an auxiliary Worker that accesses its ctx.exports | +| [integration.test.ts](test/integration.test.ts) | Integration test with a Worker that accesses its ctx.exports | +| [unit.test.ts](test/unit.test.ts) | Unit tests of ctx.exports, constructed, imported and via a Worker | diff --git a/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/index.ts b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/index.ts new file mode 100644 index 0000000..3e1c70a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/index.ts @@ -0,0 +1,13 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +export default { + async fetch(request, env, ctx) { + return new Response("👋 " + (await ctx.exports.NamedEntryPoint.greet())); + }, +} satisfies ExportedHandler; + +export class NamedEntryPoint extends WorkerEntrypoint { + greet() { + return `Hello ${this.env.NAME} from Auxiliary NamedEntryPoint!`; + } +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/tsconfig.json b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/worker-configuration.d.ts b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/worker-configuration.d.ts new file mode 100644 index 0000000..902467f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/worker-configuration.d.ts @@ -0,0 +1,9 @@ +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env { + NAME: string; + } +} +interface Env extends Cloudflare.Env {} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/wrangler.jsonc new file mode 100644 index 0000000..8fd3cf3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/auxiliary-worker/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "auxiliary-worker", + "main": "./index.ts", + // compatibility_date is required so we can run `wrangler build` + "compatibility_date": "2025-11-01", +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/global-setup.ts b/fixtures/vitest-pool-workers-examples/context-exports/global-setup.ts new file mode 100644 index 0000000..bf90b23 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/global-setup.ts @@ -0,0 +1,11 @@ +import childProcess from "node:child_process"; + +// Global setup runs inside Node.js, not `workerd` +export default function () { + const label = "Built auxiliary worker"; + console.time(label); + childProcess.execSync("wrangler build -c auxiliary-worker/wrangler.jsonc", { + cwd: __dirname, + }); + console.timeEnd(label); +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/src/index.ts b/fixtures/vitest-pool-workers-examples/context-exports/src/index.ts new file mode 100644 index 0000000..e4a4aa1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/src/index.ts @@ -0,0 +1,79 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +// This re-export will not be detected by the export guessing logic +export * from "@virtual-module"; + +// This explicit re-export will be detected by the export guessing logic +// even though it won't be able to tell what it is at build time. +export { ExplicitVirtualEntryPoint } from "@virtual-module"; + +export default { + async fetch(request, env, ctx) { + const { pathname } = new URL(request.url); + if (pathname === "/durable-object") { + const id = ctx.exports.Counter.idFromName(pathname); + const stub = ctx.exports.Counter.get(id); + return stub.fetch(request); + } + if (pathname === "/props") { + return new Response( + "👋 " + + (await ctx.exports + .NamedEntryPoint({ props: { extra: "\nAdditional props!!" } }) + .greet()) + ); + } + if (pathname === "/invalid-export") { + // @ts-expect-error we are testing invalid export access + return new Response("👋 " + (await ctx.exports.InvalidExport)); + } + + if (pathname === "/virtual-implicit") { + // We don't try to call greet() here because the entry-point will be undefined. + return new Response("👋 " + ctx.exports.ReexportedVirtualEntryPoint); + } + + if (pathname === "/virtual-explicit") { + return new Response( + "👋 " + (await ctx.exports.ExplicitVirtualEntryPoint.greet()) + ); + } + + if (pathname === "/virtual-configured") { + return new Response( + "👋 " + (await ctx.exports.ConfiguredVirtualEntryPoint.greet()) + ); + } + + if (pathname === "/virtual-durable-object") { + const id = ctx.exports.ConfiguredVirtualDurableObject.newUniqueId(); + const stub = ctx.exports.ConfiguredVirtualDurableObject.get(id); + return new Response("👋 " + (await stub.greet())); + } + + return new Response("👋 " + (await ctx.exports.NamedEntryPoint.greet())); + }, +} satisfies ExportedHandler; + +export class NamedEntryPoint extends WorkerEntrypoint { + greet() { + return ( + `Hello ${this.env.NAME} from Main NamedEntryPoint!` + + (this.ctx.props.extra ?? "") + ); + } +} + +export class Counter extends DurableObject { + count: number = 0; + + increment(by = 1) { + this.count += by; + void this.ctx.storage.put("count", this.count); + } + + fetch(request: Request) { + this.increment(); + return new Response(this.count.toString()); + } +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/context-exports/src/tsconfig.json new file mode 100644 index 0000000..38455c0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/src/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"], + "compilerOptions": { + "paths": { + // This path is used to simulate a virtual module that Vitest and TypeScript can understand, + // but esbuild (used by the vitest-pool-workers to guess exports) cannot. + "@virtual-module": ["./virtual.ts"] + } + } +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/src/virtual.ts b/fixtures/vitest-pool-workers-examples/context-exports/src/virtual.ts new file mode 100644 index 0000000..6766491 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/src/virtual.ts @@ -0,0 +1,29 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +// This is wildcard re-exported in src/index.ts so it is not detected by the export guessing logic +export class ReexportedVirtualEntryPoint extends WorkerEntrypoint { + greet() { + return `Hello ${this.env.NAME} from ReexportedVirtualEntryPoint!`; + } +} + +// This is explictly re-exported in src/index.ts so it is detected by the export guessing logic +export class ExplicitVirtualEntryPoint extends WorkerEntrypoint { + greet() { + return `Hello ${this.env.NAME} from ExplicitVirtualEntryPoint!`; + } +} + +// Although this export cannot be inferred by esbuild, it is explicitly configured in the Wrangler config via `migrations`. +export class ConfiguredVirtualDurableObject extends DurableObject { + greet() { + return `Hello ${this.env.NAME} from ConfiguredVirtualDurableObject!`; + } +} + +// Although this export cannot be inferred by esbuild, it is explicitly configured in vitest-pool-workers config via `additionalExports`. +export class ConfiguredVirtualEntryPoint extends WorkerEntrypoint { + greet() { + return `Hello ${this.env.NAME} from ConfiguredVirtualEntryPoint!`; + } +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/src/worker-configuration.d.ts b/fixtures/vitest-pool-workers-examples/context-exports/src/worker-configuration.d.ts new file mode 100644 index 0000000..25f6ac9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/src/worker-configuration.d.ts @@ -0,0 +1,11 @@ +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "Counter" | "ConfiguredVirtualDurableObject"; + } + interface Env { + NAME: string; + AUXILIARY_WORKER: Fetcher; + } +} +interface Env extends Cloudflare.Env {} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/src/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/context-exports/src/wrangler.jsonc new file mode 100644 index 0000000..af756df --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/src/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "main-worker", + "main": "./index.ts", + "compatibility_date": "2025-11-01", + "compatibility_flags": ["enable_ctx_exports"], + "vars": { + "NAME": "MainWorker", + }, + "services": [ + { + "binding": "AUXILIARY_WORKER", + "service": "auxiliary-worker", + }, + ], + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["Counter", "ConfiguredVirtualDurableObject"], + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/test/auxiliary.test.ts b/fixtures/vitest-pool-workers-examples/context-exports/test/auxiliary.test.ts new file mode 100644 index 0000000..0ebc86d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/test/auxiliary.test.ts @@ -0,0 +1,11 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +it("uses the correct context exports on the auxiliary worker", async ({ + expect, +}) => { + const response = await env.AUXILIARY_WORKER.fetch("http://example.com"); + expect(await response.text()).toMatchInlineSnapshot( + `"👋 Hello AuxiliaryWorker from Auxiliary NamedEntryPoint!"` + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/context-exports/test/durable-objects.test.ts b/fixtures/vitest-pool-workers-examples/context-exports/test/durable-objects.test.ts new file mode 100644 index 0000000..5041290 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/test/durable-objects.test.ts @@ -0,0 +1,40 @@ +import { runInDurableObject } from "cloudflare:test"; +import { exports } from "cloudflare:workers"; +import { it, vi } from "vitest"; + +it("can access imported context exports for Durable Objects", async ({ + expect, +}) => { + const id = exports.Counter.idFromName("/path"); + const stub = exports.Counter.get(id); + const response = await runInDurableObject(stub, async (obj) => { + return obj.fetch(new Request("http://example.com")); + }); + expect(await response.text()).toMatchInlineSnapshot(`"1"`); + const count = await runInDurableObject(stub, async (obj) => obj.count); + expect(count).toBe(1); +}); + +it("can access context exports for Durable Objects on exports.default", async ({ + expect, +}) => { + const response = await exports.default.fetch( + "https://example.com/durable-object" + ); + expect(await response.text()).toMatchInlineSnapshot(`"1"`); +}); + +it("will can access Durable Object context exports that could not be guessed on the exports.default worker but are described by DO migrations", async ({ + expect, +}) => { + // In this test, we are trying to access a durable-object that is wildcard (*) re-exported from a virtual module. + // This virtual module is understood by Vitest and TypeScript but not the lightweight esbuild that we use to guess exports. + // But since Durable Objects require explicit "migration" configuration in wrangler, we can still make this work. + const warnSpy = vi.spyOn(console, "warn"); + const response = await exports.default.fetch( + "http://example.com/virtual-durable-object" + ); + expect(await response.text()).toMatchInlineSnapshot( + `"👋 Hello MainWorker from ConfiguredVirtualDurableObject!"` + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/context-exports/test/integration-self.test.ts b/fixtures/vitest-pool-workers-examples/context-exports/test/integration-self.test.ts new file mode 100644 index 0000000..e92516a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/test/integration-self.test.ts @@ -0,0 +1,85 @@ +import { exports } from "cloudflare:workers"; +import { it, vi } from "vitest"; + +it("can use context exports on the main worker", async ({ expect }) => { + const response = await exports.default.fetch("http://example.com"); + expect(await response.text()).toBe( + "👋 Hello MainWorker from Main NamedEntryPoint!" + ); +}); + +it("can use context exports (parameterized with props) on the main worker", async ({ + expect, +}) => { + const response = await exports.default.fetch("http://example.com/props"); + expect(await response.text()).toBe( + "👋 Hello MainWorker from Main NamedEntryPoint!\nAdditional props!!" + ); +}); + +it("will warn on missing context exports on the main worker", async ({ + expect, +}) => { + const warnSpy = vi.spyOn(console, "warn"); + const response = await exports.default.fetch( + "http://example.com/invalid-export" + ); + expect(await response.text()).toMatchInlineSnapshot(`"👋 undefined"`); + expect(warnSpy).toHaveBeenCalledWith( + "Attempted to access 'ctx.exports.InvalidExport', which was not defined for the main Worker.\n" + + "Check that 'InvalidExport' is exported as an entry-point from the Worker.\n" + + "The '@cloudflare/vitest-pool-workers' integration tries to infer these exports by analyzing the source code of the main Worker.\n" + ); +}); + +it("will warn on implicit re-exports that will exist in production but cannot not be guessed on the main worker", async ({ + expect, +}) => { + // In this test, we are trying to access an entry-point that is wildcard (*) re-exported from a virtual module. + // This virtual module is understood by Vitest and TypeScript but not the lightweight esbuild that we use to guess exports. + const warnSpy = vi.spyOn(console, "warn"); + const response = await exports.default.fetch( + "http://example.com/virtual-implicit" + ); + expect(await response.text()).toMatchInlineSnapshot(`"👋 undefined"`); + expect(warnSpy).toHaveBeenCalledWith( + "Attempted to access 'ctx.exports.ReexportedVirtualEntryPoint', which was not defined for the main Worker.\n" + + "Check that 'ReexportedVirtualEntryPoint' is exported as an entry-point from the Worker.\n" + + "The '@cloudflare/vitest-pool-workers' integration tries to infer these exports by analyzing the source code of the main Worker.\n" + ); +}); + +it("will still guess re-exports on the main worker that cannot be fully analyzed by esbuild", async ({ + expect, +}) => { + // In this test, we are trying to access an entry-point that is explicitly re-exported from a virtual module. + // Although esbuild cannot really analyze what is being re-exported, it can at least see that something is being re-exported with that name. + const response = await exports.default.fetch( + "http://example.com/virtual-explicit" + ); + expect(await response.text()).toBe( + "👋 Hello MainWorker from ExplicitVirtualEntryPoint!" + ); +}); + +it("can access configured virtual entry points on the main worker that cannot be fully analyzed by esbuild", async ({ + expect, +}) => { + // In this test, we are trying to access an entry-point that is explicitly re-exported from a virtual module. + // Although esbuild cannot really analyze what is being re-exported, it can at least see that something is being re-exported with that name. + const response = await exports.default.fetch( + "http://example.com/virtual-configured" + ); + expect(await response.text()).toBe( + "👋 Hello MainWorker from ConfiguredVirtualEntryPoint!" + ); +}); + +it("can access imported context exports for the main worker", async ({ + expect, +}) => { + const msg = await exports.NamedEntryPoint.greet(); + expect(msg).toMatchInlineSnapshot( + `"Hello MainWorker from Main NamedEntryPoint!"` + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/context-exports/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/context-exports/test/tsconfig.json new file mode 100644 index 0000000..a13714b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/worker-configuration.d.ts"], + "compilerOptions": { + "paths": { + // This path is used to simulate a virtual module that Vitest and TypeScript can understand, + // but esbuild (used by the vitest-pool-workers to guess exports) cannot. + "@virtual-module": ["../src/virtual.ts"] + } + } +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/test/unit.test.ts b/fixtures/vitest-pool-workers-examples/context-exports/test/unit.test.ts new file mode 100644 index 0000000..c09734f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/test/unit.test.ts @@ -0,0 +1,38 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { exports as importedExports } from "cloudflare:workers"; +import { it } from "vitest"; +import worker from "../src/index"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `worker.fetch()`. +const IncomingRequest = Request; + +it("has the correct context exports from `createExecutionContext()`", async ({ + expect, +}) => { + const ctx = createExecutionContext(); + expect(await ctx.exports.NamedEntryPoint.greet()).toMatchInlineSnapshot( + `"Hello MainWorker from Main NamedEntryPoint!"` + ); +}); + +it("has the correct imported context exports", async ({ expect }) => { + expect(await importedExports.NamedEntryPoint.greet()).toMatchInlineSnapshot( + `"Hello MainWorker from Main NamedEntryPoint!"` + ); +}); + +it("can pass the context exports to a worker", async ({ expect }) => { + const request = new IncomingRequest("http://example.com"); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot( + `"👋 Hello MainWorker from Main NamedEntryPoint!"` + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/context-exports/tsconfig.json b/fixtures/vitest-pool-workers-examples/context-exports/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/context-exports/vitest.config.ts b/fixtures/vitest-pool-workers-examples/context-exports/vitest.config.ts new file mode 100644 index 0000000..6b8c11a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/context-exports/vitest.config.ts @@ -0,0 +1,44 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +// Configuration for the "auxiliary" Worker under test. +// Unfortunately, auxiliary Workers cannot load their configuration +// from wrangler config files, and must be configured with Miniflare +// `WorkerOptions`. +export const auxiliaryWorker = { + name: "auxiliary-worker", + modules: true, + scriptPath: "./auxiliary-worker/dist/index.js", // Built by `global-setup.ts` + compatibilityDate: "2025-11-01", + compatibilityFlags: ["nodejs_compat", "enable_ctx_exports"], + bindings: { + NAME: "AuxiliaryWorker", + }, +}; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./src/wrangler.jsonc" }, + miniflare: { + workers: [auxiliaryWorker], + }, + additionalExports: { + // This entrypoint is wildcard re-exported from a virtual module so we cannot automatically infer it. + ConfiguredVirtualEntryPoint: "WorkerEntrypoint", + }, + }), + ], + test: { + globalSetup: ["./global-setup.ts"], + alias: { + // This alias is used to simulate a virtual module that Vitest and TypeScript can understand, + // but esbuild (used by the vitest-pool-workers to guess exports) cannot. + "@virtual-module": "./virtual.ts", + }, + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/d1/README.md b/fixtures/vitest-pool-workers-examples/d1/README.md new file mode 100644 index 0000000..c6b6d91 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/README.md @@ -0,0 +1,8 @@ +# 📚 d1 + +This Worker implements a simple blog using D1 bindings. It uses migrations for setting up the database. These migrations are read in `vitest.config.mts`, and applied with the [test/apply-migrations.ts](test/apply-migrations.ts) setup file. + +| Test | Overview | +| --------------------------------------- | -------------------------------- | +| [queries.test.ts](test/queries.test.ts) | Unit tests for SQL query helpers | +| [routes.test.ts](test/routes.test.ts) | Integration for endpoints | diff --git a/fixtures/vitest-pool-workers-examples/d1/migrations/0000_initial.sql b/fixtures/vitest-pool-workers-examples/d1/migrations/0000_initial.sql new file mode 100644 index 0000000..e00faff --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/migrations/0000_initial.sql @@ -0,0 +1,13 @@ +-- Migration number: 0000 2024-03-01T15:05:23.955Z +CREATE TABLE users ( + username TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); +CREATE TABLE posts ( + slug TEXT PRIMARY KEY, + author TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author) REFERENCES users(username) +); +-- Migration ends here diff --git a/fixtures/vitest-pool-workers-examples/d1/migrations/0001_admin.sql b/fixtures/vitest-pool-workers-examples/d1/migrations/0001_admin.sql new file mode 100644 index 0000000..6d7fc1f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/migrations/0001_admin.sql @@ -0,0 +1,2 @@ +-- Migration number: 0001 2024-03-01T15:17:12.235Z +INSERT INTO users (username, name, email) VALUES ('admin', 'Ada Min', 'admin@example.com'); diff --git a/fixtures/vitest-pool-workers-examples/d1/src/env.d.ts b/fixtures/vitest-pool-workers-examples/d1/src/env.d.ts new file mode 100644 index 0000000..cb34da6 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/src/env.d.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./d1/src/env.d.ts -c ./d1/wrangler.jsonc --no-include-runtime` (hash: db529685c9defc6baa1cc744b765c359) +interface __BaseEnv_Env { + DATABASE?: D1Database; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface ProductionEnv { + DATABASE: D1Database; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/d1/src/index.ts b/fixtures/vitest-pool-workers-examples/d1/src/index.ts new file mode 100644 index 0000000..20ce2ea --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/src/index.ts @@ -0,0 +1,39 @@ +import { listPosts, readPost, upsertPost } from "./utils"; + +async function handleListRequest(env: Env, origin: string): Promise { + const posts = await listPosts(env); + const body = posts + .map((post) => `${origin}${post.slug}\n${post.body}`) + .join(`\n\n${"-".repeat(20)}\n`); + return new Response(body); +} + +async function handleReadRequest(env: Env, slug: string): Promise { + const post = await readPost(env, slug); + if (post === null) return new Response("Not Found", { status: 404 }); + else return new Response(post.body); +} + +async function handlePutRequest( + request: Request, + env: Env, + slug: string +): Promise { + if (slug === "/") return new Response("Method Not Allowed", { status: 405 }); + await upsertPost(env, slug, await request.text()); + return new Response(null, { status: 204 }); +} + +export default >{ + fetch(request, env) { + const { origin, pathname } = new URL(request.url); + if (request.method === "GET") { + if (pathname === "/") return handleListRequest(env, origin); + else return handleReadRequest(env, pathname); + } else if (request.method === "PUT") { + return handlePutRequest(request, env, pathname); + } else { + return new Response("Method Not Allowed", { status: 405 }); + } + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/d1/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/d1/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/d1/src/utils.ts b/fixtures/vitest-pool-workers-examples/d1/src/utils.ts new file mode 100644 index 0000000..a9e1bd7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/src/utils.ts @@ -0,0 +1,92 @@ +// ============================================================================= +// Types + +interface User { + username: string; + name: string; + email: string; +} + +interface Post { + slug: string; + author: User; + body: string; +} + +// ============================================================================= +// Queries + +type QueryResult = User & Omit; +function queryResultToPost(result: QueryResult): Post { + return { + slug: result.slug, + body: result.body, + author: { + username: result.username, + name: result.name, + email: result.email, + }, + }; +} + +export async function listPosts(env: Env): Promise { + const database = env.DATABASE; + if (!database) { + throw new Error("DATABASE binding is required"); + } + + const results = await database + .prepare( + ` + SELECT slug, body, username, name, email + FROM posts + INNER JOIN users ON posts.author = users.username + ` + ) + .all(); + return results.results.map(queryResultToPost); +} + +export async function readPost(env: Env, slug: string): Promise { + const database = env.DATABASE; + if (!database) { + throw new Error("DATABASE binding is required"); + } + + const result = await database + .prepare( + ` + SELECT slug, body, username, name, email + FROM posts + INNER JOIN users ON posts.author = users.username + WHERE slug = ?1 + ` + ) + .bind(slug) + .first(); + return result === null ? null : queryResultToPost(result); +} + +export async function upsertPost( + env: Env, + slug: string, + body: string +): Promise { + const database = env.DATABASE; + if (!database) { + throw new Error("DATABASE binding is required"); + } + + await database + .prepare( + ` + INSERT INTO posts (slug, author, body) + VALUES (?1, ?2, ?3) + ON CONFLICT (slug) DO UPDATE SET + author = ?2, + body = ?3 + ` + ) + .bind(slug, "admin", body) + .run(); +} diff --git a/fixtures/vitest-pool-workers-examples/d1/test/apply-migrations.ts b/fixtures/vitest-pool-workers-examples/d1/test/apply-migrations.ts new file mode 100644 index 0000000..1dd6e31 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/test/apply-migrations.ts @@ -0,0 +1,7 @@ +import { applyD1Migrations } from "cloudflare:test"; +import { env } from "cloudflare:workers"; + +// Setup files run outside the per-test-file storage isolation, and may be run +// multiple times. `applyD1Migrations()` only applies migrations that haven't +// already been applied, therefore it is safe to call this function here. +await applyD1Migrations(env.DATABASE, env.TEST_MIGRATIONS); diff --git a/fixtures/vitest-pool-workers-examples/d1/test/env.d.ts b/fixtures/vitest-pool-workers-examples/d1/test/env.d.ts new file mode 100644 index 0000000..afabea1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/test/env.d.ts @@ -0,0 +1,6 @@ +declare namespace Cloudflare { + interface Env { + DATABASE: D1Database; + TEST_MIGRATIONS: import("cloudflare:test").D1Migration[]; // Defined in `vitest.config.mts` + } +} diff --git a/fixtures/vitest-pool-workers-examples/d1/test/queries.test.ts b/fixtures/vitest-pool-workers-examples/d1/test/queries.test.ts new file mode 100644 index 0000000..8a7a1bd --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/test/queries.test.ts @@ -0,0 +1,33 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import { listPosts, readPost, upsertPost } from "../src/utils"; + +it("should create and read post", async ({ expect }) => { + await upsertPost(env, "/hello", "👋"); + + const post = await readPost(env, "/hello"); + expect(post).toMatchInlineSnapshot(` + { + "author": { + "email": "admin@example.com", + "name": "Ada Min", + "username": "admin", + }, + "body": "👋", + "slug": "/hello", + } + `); +}); + +it("should list posts", async ({ expect }) => { + await upsertPost(env, "/one", "1"); + await upsertPost(env, "/two", "2"); + await upsertPost(env, "/three", "3"); + + const posts = await listPosts(env); + expect(posts.length).toBe(4); + expect(posts[0].body).toBe("👋"); + expect(posts[1].body).toBe("1"); + expect(posts[2].body).toBe("2"); + expect(posts[3].body).toBe("3"); +}); diff --git a/fixtures/vitest-pool-workers-examples/d1/test/routes.test.ts b/fixtures/vitest-pool-workers-examples/d1/test/routes.test.ts new file mode 100644 index 0000000..26eb508 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/test/routes.test.ts @@ -0,0 +1,61 @@ +import { env, exports } from "cloudflare:workers"; +import { it } from "vitest"; +import { upsertPost } from "../src/utils"; + +it("should create and read post", async ({ expect }) => { + let response = await exports.default.fetch("https://example.com/hello", { + method: "PUT", + body: "👋", + }); + expect(response.status).toBe(204); + + response = await exports.default.fetch("https://example.com/hello"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("👋"); +}); + +it("should list posts", async ({ expect }) => { + await upsertPost(env, "/one", "1"); + await upsertPost(env, "/two", "2"); + await upsertPost(env, "/three", "3"); + + const response = await exports.default.fetch("https://example.com/"); + expect(response.status).toBe(200); + expect(await response.text()).toMatchInlineSnapshot(` + "https://example.com/hello + 👋 + + -------------------- + https://example.com/one + 1 + + -------------------- + https://example.com/two + 2 + + -------------------- + https://example.com/three + 3" + `); +}); + +it("should reject invalid method", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/hello", { + method: "POST", + body: "👋", + }); + expect(response.status).toBe(405); +}); + +it("should respond with not found for invalid slugs", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/bad"); + expect(response.status).toBe(404); +}); + +it("shouldn't allow creating post at root", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/", { + method: "PUT", + body: "👋", + }); + expect(response.status).toBe(405); +}); diff --git a/fixtures/vitest-pool-workers-examples/d1/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/d1/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/d1/tsconfig.json b/fixtures/vitest-pool-workers-examples/d1/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/d1/vitest.config.ts b/fixtures/vitest-pool-workers-examples/d1/vitest.config.ts new file mode 100644 index 0000000..0d05cab --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/vitest.config.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { + cloudflareTest, + readD1Migrations, +} from "@cloudflare/vitest-pool-workers"; +import { defineConfig, defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default defineConfig(async () => { + // Read all migrations in the `migrations` directory + const migrationsPath = path.join(__dirname, "migrations"); + const migrations = await readD1Migrations(migrationsPath); + + return mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + environment: "production", + }, + miniflare: { + // Add a test-only binding for migrations, so we can apply them in a + // setup file + bindings: { TEST_MIGRATIONS: migrations }, + }, + }), + ], + test: { + setupFiles: ["./test/apply-migrations.ts"], + }, + }) + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/d1/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/d1/wrangler.jsonc new file mode 100644 index 0000000..c8bff04 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/d1/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "name": "d1", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "env": { + "production": { + "d1_databases": [ + { + "binding": "DATABASE", + "database_name": "database", + "database_id": "00000000-0000-0000-0000-000000000000", + }, + ], + }, + }, +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/README.md b/fixtures/vitest-pool-workers-examples/durable-objects-exports/README.md new file mode 100644 index 0000000..8142b0c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/README.md @@ -0,0 +1,9 @@ +# 📌 durable-objects-exports + +This Worker mirrors the simple [`durable-objects`](../durable-objects/) fixture, but configures its Durable Objects via the declarative [`exports`](https://developers.cloudflare.com/durable-objects/reference/durable-object-exports) field in `wrangler.jsonc` instead of the legacy `migrations` array. + +`Counter` is also declared as a regular binding under `durable_objects.bindings`. `UnboundCounter` has no binding and is reachable only via `ctx.exports.UnboundCounter` from inside the Worker — both forms must work for `exports` to be a full replacement for `migrations`. + +| Test | Overview | +| --------------------------------------- | ---------------------------------------------------------------------------------- | +| [exports.test.ts](test/exports.test.ts) | Bound + unbound DOs declared via `exports`, with SQLite storage and direct access. | diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/env.d.ts b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/env.d.ts new file mode 100644 index 0000000..b31a1a2 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=./durable-objects-exports/wrangler.jsonc --include-runtime=false ./durable-objects-exports/src/env.d.ts` (hash: a1db05673b096df6d90243959ccaa4bd) +interface __BaseEnv_Env { + COUNTER: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "Counter" | "UnboundCounter"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/index.ts b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/index.ts new file mode 100644 index 0000000..e9d35c6 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/index.ts @@ -0,0 +1,52 @@ +import { DurableObject } from "cloudflare:workers"; + +class SqlCounter extends DurableObject { + count: number = 0; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + void ctx.blockConcurrencyWhile(async () => { + this.count = (await ctx.storage.get("count")) ?? 0; + }); + } + + increment(by = 1) { + this.count += by; + void this.ctx.storage.put("count", this.count); + return this.count; + } + + // Touches the SQLite-backed API so callers can confirm the class is + // provisioned with `storage: "sqlite"` (this throws on a legacy-KV DO). + sqliteOk() { + return this.ctx.storage.sql.databaseSize; + } +} + +// Bound class — has a `durable_objects.bindings` entry. +export class Counter extends SqlCounter { + fetch(_request: Request) { + return new Response(this.increment().toString()); + } +} + +// Unbound class — declared only via `exports`, reached via `ctx.exports`. +export class UnboundCounter extends SqlCounter { + fetch(_request: Request) { + return new Response(this.increment().toString()); + } +} + +export default >{ + fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname === "/unbound") { + const id = ctx.exports.UnboundCounter.idFromName("/unbound"); + const stub = ctx.exports.UnboundCounter.get(id); + return stub.fetch(request); + } + const id = env.COUNTER.idFromName(url.pathname); + const stub = env.COUNTER.get(id); + return stub.fetch(request); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/exports.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/exports.test.ts new file mode 100644 index 0000000..4815deb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/exports.test.ts @@ -0,0 +1,45 @@ +import { listDurableObjectIds, runInDurableObject } from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { it } from "vitest"; +import { Counter } from "../src/"; + +it("provisions a bound DO declared via `exports` with SQLite storage", async ({ + expect, +}) => { + // Hit the default fetch handler — exercises the bound DO end-to-end. + let response = await exports.default.fetch("https://example.com/path"); + expect(await response.text()).toBe("1"); + + // Direct access via `runInDurableObject` — proves the class is the same + // instance class and that its SQLite storage works (would throw on a + // legacy-KV DO). + const id = env.COUNTER.idFromName("/path"); + const stub = env.COUNTER.get(id); + response = await runInDurableObject(stub, (instance: Counter) => { + expect(instance).toBeInstanceOf(Counter); + // `sqliteOk()` only succeeds for sqlite-backed DOs; the assertion below + // is the actual signal that `exports.Counter.storage = "sqlite"` was + // correctly threaded through the local-dev SQLite class-name map. + expect(typeof instance.sqliteOk()).toBe("number"); + return instance.fetch(new Request("https://example.com/path")); + }); + expect(await response.text()).toBe("2"); + + // And that the bound namespace exposes the usual helpers. + const ids = await listDurableObjectIds(env.COUNTER); + expect(ids.map((i) => i.toString())).toContain(id.toString()); +}); + +it("reaches an unbound DO declared only via `exports` through `ctx.exports`", async ({ + expect, +}) => { + // `UnboundCounter` has no `durable_objects.bindings` entry — it is reachable + // only via `ctx.exports.UnboundCounter` from inside the Worker. The default + // handler routes `/unbound` to it; if `additionalUnboundDurableObjects` was + // not threaded through from `exports`, this fetch would fail. + let response = await exports.default.fetch("https://example.com/unbound"); + expect(await response.text()).toBe("1"); + + response = await exports.default.fetch("https://example.com/unbound"); + expect(await response.text()).toBe("2"); +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects-exports/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/vitest.config.ts b/fixtures/vitest-pool-workers-examples/durable-objects-exports/vitest.config.ts new file mode 100644 index 0000000..dfaddb1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/vitest.config.ts @@ -0,0 +1,19 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + test: { + name: "@scoped/durable-objects-exports", + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects-exports/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/durable-objects-exports/wrangler.jsonc new file mode 100644 index 0000000..14f5b2e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects-exports/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "durable-objects-exports", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "durable_objects": { + "bindings": [ + { + "name": "COUNTER", + "class_name": "Counter", + }, + ], + }, + // Declarative DO `exports` — replaces the legacy `migrations` array. + // `Counter` is also bound (above); `UnboundCounter` is reachable only via + // `ctx.exports.UnboundCounter`. + "exports": { + "Counter": { "type": "durable-object", "storage": "sqlite" }, + "UnboundCounter": { "type": "durable-object", "storage": "sqlite" }, + }, +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/README.md b/fixtures/vitest-pool-workers-examples/durable-objects/README.md new file mode 100644 index 0000000..018855f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/README.md @@ -0,0 +1,9 @@ +# 📌 durable-objects + +This Worker implements a counter with Durable Objects. Each object holds a single count. + +| Test | Overview | +| --------------------------------------------------- | ---------------------------------------------------------------------- | +| [direct-access.test.ts](test/direct-access.test.ts) | Tests for endpoints that also access object instance members directly | +| [alarm.test.ts](test/alarm.test.ts) | Tests that immediately execute object alarms | +| [eviction.test.ts](test/eviction.test.ts) | Tests eviction, storage preservation and WebSocket hibernation/closing | diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts new file mode 100644 index 0000000..1dfacd4 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./durable-objects/src/env.d.ts -c ./durable-objects/wrangler.jsonc --no-include-runtime` (hash: dbeecb58f42b6b6ca9742dccb5c058cf) +interface __BaseEnv_Env { + COUNTER: DurableObjectNamespace; + SQL: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "Counter" | "SQLiteDurableObject"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts new file mode 100644 index 0000000..ce2dd7f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts @@ -0,0 +1,78 @@ +import { DurableObject } from "cloudflare:workers"; + +export class Counter extends DurableObject { + count: number = 0; + #webSocketMessages: string[] = []; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + void ctx.blockConcurrencyWhile(async () => { + this.count = (await ctx.storage.get("count")) ?? 0; + }); + } + + increment(by = 1) { + this.count += by; + void this.ctx.storage.put("count", this.count); + } + + fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname === "/websocket-order") { + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + if (url.pathname === "/websocket-order-log") { + return Response.json(this.#webSocketMessages); + } + if (url.pathname === "/redirect") { + return Response.redirect("https://example.com/redirected", 302); + } + this.increment(); + return new Response(this.count.toString()); + } + + alarm() { + this.count = 0; + void this.ctx.storage.put("count", this.count); + } + + scheduleReset(afterMillis: number) { + void this.ctx.storage.setAlarm(Date.now() + afterMillis); + } + + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + const value = + typeof message === "string" ? message : new TextDecoder().decode(message); + this.#webSocketMessages.push(value); + ws.send(value); + } + + webSocketClose() {} + + webSocketError() {} +} + +export class SQLiteDurableObject extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + fetch() { + return new Response(this.ctx.storage.sql.databaseSize.toString()); + } +} + +export default >{ + fetch(request, env) { + const { pathname } = new URL(request.url); + if (pathname === "/sql") { + const id = env.SQL.idFromName(pathname); + const stub = env.SQL.get(id); + return stub.fetch(request); + } + const id = env.COUNTER.idFromName(pathname); + const stub = env.COUNTER.get(id); + return stub.fetch(request); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/alarm.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/alarm.test.ts new file mode 100644 index 0000000..2430c44 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/alarm.test.ts @@ -0,0 +1,30 @@ +import { runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import { Counter } from "../src/"; + +it("immediately executes alarm", async ({ expect }) => { + // Schedule alarm by directly calling instance method + const id = env.COUNTER.newUniqueId(); + const stub = env.COUNTER.get(id); + await runInDurableObject(stub, (instance: Counter) => { + instance.increment(3); + instance.scheduleReset(60_000); + }); + + // Check counter has non-zero value + let response = await stub.fetch("http://placeholder"); + expect(await response.text()).toBe("4"); + + // Immediately execute the alarm to reset the counter + let ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(true); // ...as there was an alarm scheduled + + // Check counter value was reset + response = await stub.fetch("http://placeholder"); + expect(await response.text()).toBe("1"); + + // Check trying to execute the alarm again does nothing + ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(false); // ...as there was no alarm scheduled +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/direct-access.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/direct-access.test.ts new file mode 100644 index 0000000..cbab619 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/direct-access.test.ts @@ -0,0 +1,48 @@ +import { listDurableObjectIds, runInDurableObject } from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { it } from "vitest"; +import { Counter } from "../src/"; + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/9907 +it("handles redirect responses returned from runInDurableObject callback", async ({ + expect, +}) => { + const id = env.COUNTER.idFromName("/redirect-test"); + const stub = env.COUNTER.get(id); + const response = await runInDurableObject(stub, (instance: Counter) => { + const request = new Request("https://example.com/redirect"); + return instance.fetch(request); + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe( + "https://example.com/redirected" + ); +}); + +it("increments count and allows direct access to instance/storage", async ({ + expect, +}) => { + // Check access through `fetch()` handler + let response = await exports.default.fetch("https://example.com/path"); + expect(await response.text()).toBe("1"); + + // Check sending request directly to instance + const id = env.COUNTER.idFromName("/path"); + const stub = env.COUNTER.get(id); + response = await runInDurableObject(stub, (instance: Counter) => { + expect(instance).toBeInstanceOf(Counter); // Exact same class as import + const request = new Request("https://example.com/path"); + return instance.fetch(request); + }); + expect(await response.text()).toBe("2"); + + // Check direct access to instance fields and storage + await runInDurableObject(stub, async (instance: Counter, state) => { + expect(instance.count).toBe(2); + expect(await state.storage.get("count")).toBe(2); + }); + + // Check IDs can be listed + const ids = await listDurableObjectIds(env.COUNTER); + expect(ids.map((i) => i.toString())).toContain(id.toString()); +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/eviction.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/eviction.test.ts new file mode 100644 index 0000000..5e1b1e0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/eviction.test.ts @@ -0,0 +1,121 @@ +import { + evictAllDurableObjects, + evictDurableObject, + runInDurableObject, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import { Counter } from "../src/"; + +function getResponseWebSocket(response: Response) { + const socket = response.webSocket; + if (socket === null || socket === undefined) { + throw new TypeError("Expected WebSocket response"); + } + return socket; +} + +function waitForMessage(socket: WebSocket) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for WebSocket message")); + }, 10_000); + socket.addEventListener("message", (event) => { + clearTimeout(timeout); + resolve( + typeof event.data === "string" + ? event.data + : new TextDecoder().decode(event.data as ArrayBuffer) + ); + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("WebSocket error while waiting for message")); + }); + }); +} + +function waitForClose(socket: WebSocket) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for WebSocket close")); + }, 10_000); + socket.addEventListener("close", (event) => { + clearTimeout(timeout); + resolve(event); + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("WebSocket error while waiting for close")); + }); + }); +} + +it("resets in-memory state but preserves storage on targeted eviction", async ({ + expect, +}) => { + const id = env.COUNTER.idFromName(`evict-${crypto.randomUUID()}`); + const stub = env.COUNTER.get(id); + + // Persist `count = 2` through the `fetch()` handler + expect(await (await stub.fetch("https://example.com/")).text()).toBe("1"); + expect(await (await stub.fetch("https://example.com/")).text()).toBe("2"); + + // Corrupt in-memory state without persisting it to storage + await runInDurableObject(stub, (instance: Counter) => { + instance.count = 999; + }); + + await evictDurableObject(stub, { webSockets: "hibernate" }); + + // After eviction the instance is torn down: the in-memory `999` is discarded + // and `count` is reloaded from storage (`2`), so the next increment yields `3` + expect(await (await stub.fetch("https://example.com/")).text()).toBe("3"); +}); + +it("resets all running instances with bulk eviction", async ({ expect }) => { + const id = env.COUNTER.idFromName(`evict-all-${crypto.randomUUID()}`); + const stub = env.COUNTER.get(id); + + expect(await (await stub.fetch("https://example.com/")).text()).toBe("1"); + await runInDurableObject(stub, (instance: Counter) => { + instance.count = 999; + }); + + await evictAllDurableObjects(); + + expect(await (await stub.fetch("https://example.com/")).text()).toBe("2"); +}); + +it("hibernates WebSockets across eviction", async ({ expect }) => { + const id = env.COUNTER.idFromName(`evict-ws-${crypto.randomUUID()}`); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com/websocket-order", { + headers: { Upgrade: "websocket" }, + }); + const socket = getResponseWebSocket(response); + socket.accept(); + + await evictDurableObject(stub); + + // The WebSocket should be hibernated rather than closed, so messages still + // round-trip after eviction (waking the Durable Object back up) + const messagePromise = waitForMessage(socket); + socket.send("after-eviction"); + expect(await messagePromise).toBe("after-eviction"); + socket.close(1000, "done"); +}); + +it("closes WebSockets when requested during eviction", async ({ expect }) => { + const id = env.COUNTER.idFromName(`evict-ws-close-${crypto.randomUUID()}`); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com/websocket-order", { + headers: { Upgrade: "websocket" }, + }); + const socket = getResponseWebSocket(response); + socket.accept(); + + const closePromise = waitForClose(socket); + await evictDurableObject(stub, { webSockets: "close" }); + expect(await closePromise).toBeDefined(); +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/sqlite-in-do.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/sqlite-in-do.test.ts new file mode 100644 index 0000000..b65daef --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/sqlite-in-do.test.ts @@ -0,0 +1,7 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("enables SQL API with migrations", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/sql"); + expect(await response.text()).toBe("4096"); +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts new file mode 100644 index 0000000..b72bf3d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts @@ -0,0 +1,79 @@ +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +const orderingAttempts = 5; +const orderingMessages = 100; + +function expectedOrderingMessages() { + return Array.from({ length: orderingMessages }, (_, i) => `message-${i}`); +} + +function getMessageData(event: MessageEvent) { + if (typeof event.data === "string") { + return event.data; + } + if (event.data instanceof ArrayBuffer) { + return new TextDecoder().decode(event.data); + } + throw new TypeError( + `Unexpected WebSocket message type: ${typeof event.data}` + ); +} + +function waitForMessages(socket: WebSocket, count: number) { + return new Promise((resolve, reject) => { + const messages: string[] = []; + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for ${count} WebSocket messages`)); + }, 10_000); + + socket.addEventListener("message", (event) => { + messages.push(getMessageData(event)); + if (messages.length === count) { + clearTimeout(timeout); + resolve(messages); + } + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("WebSocket error while waiting for messages")); + }); + }); +} + +function getResponseWebSocket(response: Response) { + const socket = response.webSocket; + if (socket === null || socket === undefined) { + throw new TypeError("Expected WebSocket response"); + } + return socket; +} + +it("preserves hibernatable WebSocket message order", async ({ expect }) => { + for (let attempt = 0; attempt < orderingAttempts; attempt++) { + const id = env.COUNTER.idFromName( + `websocket-ordering-${crypto.randomUUID()}-${attempt}` + ); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com/websocket-order", { + headers: { Upgrade: "websocket" }, + }); + const socket = getResponseWebSocket(response); + const expected = expectedOrderingMessages(); + const messagesPromise = waitForMessages(socket, orderingMessages); + + socket.accept(); + for (const message of expected) { + socket.send(message); + } + + expect(await messagesPromise).toEqual(expected); + + const logResponse = await stub.fetch( + "https://example.com/websocket-order-log" + ); + expect(await logResponse.json()).toEqual(expected); + + socket.close(1000, "done"); + } +}); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/tsconfig.json b/fixtures/vitest-pool-workers-examples/durable-objects/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/vitest.config.ts b/fixtures/vitest-pool-workers-examples/durable-objects/vitest.config.ts new file mode 100644 index 0000000..ce613dc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/vitest.config.ts @@ -0,0 +1,19 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + test: { + name: "@scoped/durable-objects", + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc new file mode 100644 index 0000000..53dc8c9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc @@ -0,0 +1,24 @@ +{ + "name": "durable-objects", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "durable_objects": { + "bindings": [ + { + "name": "COUNTER", + "class_name": "Counter", + }, + { + "name": "SQL", + "class_name": "SQLiteDurableObject", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["Counter"], + "new_sqlite_classes": ["SQLiteDurableObject"], + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts new file mode 100644 index 0000000..df34ef5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}!`; +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts new file mode 100644 index 0000000..f50ae23 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts @@ -0,0 +1,25 @@ +import { DurableObject } from "cloudflare:workers"; + +// Durable Object that uses dynamic import() in fetch handler. +// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387 +export class GreeterDO extends DurableObject { + async fetch(request: Request): Promise { + const { greet } = await import("./greeting"); + return new Response(greet("DO")); + } +} + +export default { + async fetch( + request: Request, + _env: unknown, + _ctx: ExecutionContext + ): Promise { + // Dynamic import inside a fetch handler — this is the pattern that + // triggers the cross-DO I/O violation in vitest-pool-workers 0.13.x + // when called via `exports.default.fetch()` in tests. + // See: https://github.com/cloudflare/workers-sdk/issues/12924 + const { greet } = await import("./greeting"); + return new Response(greet("World")); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts new file mode 100644 index 0000000..e497300 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./dynamic-import/src/worker-configuration.d.ts -c ./dynamic-import/wrangler.jsonc --no-include-runtime` (hash: 3db8a5d08906b2ae83ef9a0539ebae04) +interface __BaseEnv_Env { + GREETER: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "GreeterDO"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts new file mode 100644 index 0000000..1c950f9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts @@ -0,0 +1,29 @@ +import { env } from "cloudflare:workers"; +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/12924 +// +// Calling exports.default.fetch() on a worker whose fetch handler uses a +// dynamic import() would hang with "Cannot perform I/O on behalf of a +// different Durable Object". Pre-loading the module (e.g. via a static +// import of the worker) masks the bug by caching the module. +it("exports.default.fetch() with dynamic import()", async ({ expect }) => { + const response = await exports.default.fetch( + new Request("https://example.com/") + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello, World!"); +}); + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387 +// +// Dynamic import() inside a Durable Object fetch handler has the same +// cross-context I/O violation when the module isn't already cached. +it("Durable Object fetch with dynamic import()", async ({ expect }) => { + const id = env.GREETER.idFromName("test"); + const stub = env.GREETER.get(id); + const response = await stub.fetch(new Request("https://example.com/")); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello, DO!"); +}); diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json new file mode 100644 index 0000000..20248fc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts new file mode 100644 index 0000000..4217062 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc new file mode 100644 index 0000000..715d079 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "dynamic-import", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "durable_objects": { + "bindings": [{ "name": "GREETER", "class_name": "GreeterDO" }], + }, + "migrations": [{ "tag": "v1", "new_classes": ["GreeterDO"] }], +} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/README.md b/fixtures/vitest-pool-workers-examples/hyperdrive/README.md new file mode 100644 index 0000000..9997f46 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/README.md @@ -0,0 +1,7 @@ +# 🚀 hyperdrive + +This Worker establishes a TCP connection with an echo server via Hyperdrive. The echo server is started/stopped by [global-setup.ts](global-setup.ts) on a random port, which is then provided to [vitest.config.mts](vitest.config.mts). In a real worker, Hyperdrive would be used with a database instead. + +| Test | Overview | +| --------------------------------- | -------------------------------- | +| [echo.test.ts](test/echo.test.ts) | Integration test using `exports` | diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/env.d.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/env.d.ts new file mode 100644 index 0000000..d409ce6 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/env.d.ts @@ -0,0 +1,7 @@ +declare module "vitest" { + interface ProvidedContext { + echoServerPort: number; + } +} + +export {}; diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/global-setup.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/global-setup.ts new file mode 100644 index 0000000..aa60834 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/global-setup.ts @@ -0,0 +1,40 @@ +import assert from "node:assert"; +import events from "node:events"; +import net from "node:net"; +import util from "node:util"; +import type { TestProject } from "vitest/node"; + +export const POSTGRES_SSL_REQUEST_PACKET = Buffer.from([ + 0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f, +]); + +// Global setup runs inside Node.js, not `workerd` +export default async function ({ provide }: TestProject) { + // Start echo server on random port + const server = net.createServer((socket) => { + socket.on("data", (chunk) => { + // on postgres ssl request packet respond with 'N' to indicate no SSL support + if (POSTGRES_SSL_REQUEST_PACKET.equals(chunk)) { + socket.write("N"); + } else { + socket.write(chunk); + } + }); + }); + const listeningPromise = events.once(server, "listening"); + server.listen(0, "127.0.0.1"); + await listeningPromise; + + // Get randomly assigned port and provide for config + const address = server.address(); + assert(typeof address === "object" && address !== null); + const port = address.port; + provide("echoServerPort", port); + console.log(`Started echo server on port ${port}`); + + return async () => { + // Stop echo server on teardown + await util.promisify(server.close.bind(server))(); + console.log("Stopped echo server"); + }; +} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/src/env.d.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/src/env.d.ts new file mode 100644 index 0000000..41053a1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./hyperdrive/src/env.d.ts -c ./hyperdrive/wrangler.jsonc --no-include-runtime` (hash: b649c979f0061521ef2170b65cdd0101) +interface __BaseEnv_Env { + ECHO_SERVER_HYPERDRIVE: Hyperdrive; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/src/index.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/src/index.ts new file mode 100644 index 0000000..e4d3aa6 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/src/index.ts @@ -0,0 +1,17 @@ +export default >{ + async fetch(request, env, ctx) { + if (request.method !== "POST") return new Response("Method Not Allowed"); + if (request.body === null) return new Response(); + + const socket = env.ECHO_SERVER_HYPERDRIVE.connect(); + const writer = socket.writable.getWriter(); + + // parse body + const value = await request.text(); + await writer.write(new TextEncoder().encode(value)); + const result = await socket.readable.getReader().read(); + + writer.close(); + return new Response(result.value); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/hyperdrive/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/test/echo.test.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/test/echo.test.ts new file mode 100644 index 0000000..8cafd1e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/test/echo.test.ts @@ -0,0 +1,11 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("responds with request body", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/", { + method: "POST", + body: "body", + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("body"); +}); diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/hyperdrive/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/tsconfig.json b/fixtures/vitest-pool-workers-examples/hyperdrive/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/vitest.config.ts b/fixtures/vitest-pool-workers-examples/hyperdrive/vitest.config.ts new file mode 100644 index 0000000..c2be5c3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/vitest.config.ts @@ -0,0 +1,29 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest(({ inject }) => { + // Provided in `global-setup.ts` + const echoServerPort = inject("echoServerPort"); + + return { + miniflare: { + hyperdrives: { + ECHO_SERVER_HYPERDRIVE: `postgres://user:pass@127.0.0.1:${echoServerPort}/db`, + }, + }, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }; + }), + ], + test: { + globalSetup: ["./global-setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/hyperdrive/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/hyperdrive/wrangler.jsonc new file mode 100644 index 0000000..12d2f75 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/hyperdrive/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "hyperdrive", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "hyperdrive": [ + { + "binding": "ECHO_SERVER_HYPERDRIVE", + "id": "00000000000000000000000000000000", + "localConnectionString": "postgres://user:pass@127.0.0.1/db", // Overridden in `vitest.config.mts` + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/images/README.md b/fixtures/vitest-pool-workers-examples/images/README.md new file mode 100644 index 0000000..9410bf7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/README.md @@ -0,0 +1,3 @@ +# images + +This Worker returns information about an image that is POSTed to it and can perform Hosted Images CRUD operations. diff --git a/fixtures/vitest-pool-workers-examples/images/src/env.d.ts b/fixtures/vitest-pool-workers-examples/images/src/env.d.ts new file mode 100644 index 0000000..af4e8db --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./images/src/env.d.ts -c ./images/wrangler.jsonc --no-include-runtime` (hash: 8c4b4847f688035a3be996ec1a9112ba) +interface __BaseEnv_Env { + IMAGES: ImagesBinding; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/images/src/index.ts b/fixtures/vitest-pool-workers-examples/images/src/index.ts new file mode 100644 index 0000000..75901c9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/src/index.ts @@ -0,0 +1,8 @@ +export default { + async fetch(request, env, _ctx) { + return Response.json(await env.IMAGES.info(request.body!)); + }, +} satisfies ExportedHandler; +// ^ Using `satisfies` provides type checking/completions for `ExportedHandler` +// whilst still allowing us to call `worker.fetch()` and `worker.queue()` in +// tests without asserting they're defined. diff --git a/fixtures/vitest-pool-workers-examples/images/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/images/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/images/test/images.test.ts b/fixtures/vitest-pool-workers-examples/images/test/images.test.ts new file mode 100644 index 0000000..1686f20 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/test/images.test.ts @@ -0,0 +1,100 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +const TINY_PNG = new Uint8Array([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 18, 0, + 0, 0, 18, 8, 6, 0, 0, 0, 86, 206, 142, 87, 0, 0, 0, 4, 103, 65, 77, 65, 0, 0, + 177, 143, 11, 252, 97, 5, 0, 0, 0, 32, 99, 72, 82, 77, 0, 0, 122, 38, 0, 0, + 128, 132, 0, 0, 250, 0, 0, 0, 128, 232, 0, 0, 117, 48, 0, 0, 234, 96, 0, 0, + 58, 152, 0, 0, 23, 112, 156, 186, 81, 60, 0, 0, 0, 120, 101, 88, 73, 102, 77, + 77, 0, 42, 0, 0, 0, 8, 0, 4, 1, 18, 0, 3, 0, 0, 0, 1, 0, 1, 0, 0, 1, 26, 0, 5, + 0, 0, 0, 1, 0, 0, 0, 62, 1, 27, 0, 5, 0, 0, 0, 1, 0, 0, 0, 70, 135, 105, 0, 4, + 0, 0, 0, 1, 0, 0, 0, 78, 0, 0, 0, 0, 0, 0, 0, 72, 0, 0, 0, 1, 0, 0, 0, 72, 0, + 0, 0, 1, 0, 3, 160, 1, 0, 3, 0, 0, 0, 1, 0, 1, 0, 0, 160, 2, 0, 4, 0, 0, 0, 1, + 0, 0, 0, 18, 160, 3, 0, 4, 0, 0, 0, 1, 0, 0, 0, 18, 0, 0, 0, 0, 117, 55, 28, + 226, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 11, 19, 0, 0, 11, 19, 1, 0, 154, 156, + 24, 0, 0, 2, 146, 105, 84, 88, 116, 88, 77, 76, 58, 99, 111, 109, 46, 97, 100, + 111, 98, 101, 46, 120, 109, 112, 0, 0, 0, 0, 0, 60, 120, 58, 120, 109, 112, + 109, 101, 116, 97, 32, 120, 109, 108, 110, 115, 58, 120, 61, 34, 97, 100, 111, + 98, 101, 58, 110, 115, 58, 109, 101, 116, 97, 47, 34, 32, 120, 58, 120, 109, + 112, 116, 107, 61, 34, 88, 77, 80, 32, 67, 111, 114, 101, 32, 54, 46, 48, 46, + 48, 34, 62, 10, 32, 32, 32, 60, 114, 100, 102, 58, 82, 68, 70, 32, 120, 109, + 108, 110, 115, 58, 114, 100, 102, 61, 34, 104, 116, 116, 112, 58, 47, 47, 119, + 119, 119, 46, 119, 51, 46, 111, 114, 103, 47, 49, 57, 57, 57, 47, 48, 50, 47, + 50, 50, 45, 114, 100, 102, 45, 115, 121, 110, 116, 97, 120, 45, 110, 115, 35, + 34, 62, 10, 32, 32, 32, 32, 32, 32, 60, 114, 100, 102, 58, 68, 101, 115, 99, + 114, 105, 112, 116, 105, 111, 110, 32, 114, 100, 102, 58, 97, 98, 111, 117, + 116, 61, 34, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 120, 109, + 108, 110, 115, 58, 116, 105, 102, 102, 61, 34, 104, 116, 116, 112, 58, 47, 47, + 110, 115, 46, 97, 100, 111, 98, 101, 46, 99, 111, 109, 47, 116, 105, 102, 102, + 47, 49, 46, 48, 47, 34, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 120, 109, 108, 110, 115, 58, 101, 120, 105, 102, 61, 34, 104, 116, 116, 112, + 58, 47, 47, 110, 115, 46, 97, 100, 111, 98, 101, 46, 99, 111, 109, 47, 101, + 120, 105, 102, 47, 49, 46, 48, 47, 34, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 60, 116, 105, 102, 102, 58, 89, 82, 101, 115, 111, 108, 117, 116, 105, + 111, 110, 62, 55, 50, 60, 47, 116, 105, 102, 102, 58, 89, 82, 101, 115, 111, + 108, 117, 116, 105, 111, 110, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, + 116, 105, 102, 102, 58, 88, 82, 101, 115, 111, 108, 117, 116, 105, 111, 110, + 62, 55, 50, 60, 47, 116, 105, 102, 102, 58, 88, 82, 101, 115, 111, 108, 117, + 116, 105, 111, 110, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 116, 105, + 102, 102, 58, 79, 114, 105, 101, 110, 116, 97, 116, 105, 111, 110, 62, 49, 60, + 47, 116, 105, 102, 102, 58, 79, 114, 105, 101, 110, 116, 97, 116, 105, 111, + 110, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 101, 120, 105, 102, 58, + 80, 105, 120, 101, 108, 88, 68, 105, 109, 101, 110, 115, 105, 111, 110, 62, + 54, 52, 60, 47, 101, 120, 105, 102, 58, 80, 105, 120, 101, 108, 88, 68, 105, + 109, 101, 110, 115, 105, 111, 110, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 60, 101, 120, 105, 102, 58, 67, 111, 108, 111, 114, 83, 112, 97, 99, 101, 62, + 49, 60, 47, 101, 120, 105, 102, 58, 67, 111, 108, 111, 114, 83, 112, 97, 99, + 101, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 101, 120, 105, 102, 58, + 80, 105, 120, 101, 108, 89, 68, 105, 109, 101, 110, 115, 105, 111, 110, 62, + 54, 52, 60, 47, 101, 120, 105, 102, 58, 80, 105, 120, 101, 108, 89, 68, 105, + 109, 101, 110, 115, 105, 111, 110, 62, 10, 32, 32, 32, 32, 32, 32, 60, 47, + 114, 100, 102, 58, 68, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 62, + 10, 32, 32, 32, 60, 47, 114, 100, 102, 58, 82, 68, 70, 62, 10, 60, 47, 120, + 58, 120, 109, 112, 109, 101, 116, 97, 62, 10, 104, 194, 171, 3, 0, 0, 2, 38, + 73, 68, 65, 84, 56, 17, 221, 83, 75, 104, 19, 81, 20, 61, 239, 51, 153, 73, + 172, 197, 46, 84, 208, 69, 141, 165, 65, 4, 65, 232, 78, 193, 79, 93, 73, 169, + 136, 216, 224, 70, 16, 92, 21, 4, 17, 177, 59, 237, 168, 117, 229, 66, 5, 247, + 130, 203, 180, 43, 215, 74, 179, 113, 227, 194, 141, 32, 130, 24, 20, 162, 96, + 45, 90, 219, 152, 100, 38, 243, 222, 243, 188, 244, 131, 17, 68, 196, 149, 94, + 114, 185, 111, 114, 207, 59, 247, 158, 123, 103, 128, 127, 202, 220, 4, 148, + 171, 208, 29, 68, 247, 28, 67, 255, 177, 0, 79, 240, 171, 75, 158, 248, 231, + 92, 79, 133, 56, 134, 156, 222, 75, 208, 75, 8, 81, 70, 150, 222, 24, 26, 113, + 210, 92, 200, 12, 74, 82, 98, 73, 65, 206, 229, 174, 214, 30, 8, 1, 114, 17, + 3, 48, 172, 218, 6, 179, 35, 137, 136, 97, 215, 19, 233, 76, 241, 44, 145, 15, + 181, 18, 176, 214, 49, 201, 76, 32, 209, 106, 102, 115, 133, 107, 239, 38, 92, + 165, 162, 102, 81, 70, 185, 12, 227, 239, 116, 137, 214, 73, 86, 226, 161, + 109, 188, 182, 95, 106, 219, 128, 21, 79, 36, 16, 117, 44, 82, 226, 40, 211, + 55, 1, 211, 215, 159, 15, 147, 47, 175, 46, 69, 55, 113, 215, 19, 116, 71, 80, + 38, 218, 15, 81, 204, 194, 52, 103, 74, 59, 133, 75, 159, 70, 129, 28, 252, + 150, 202, 140, 215, 188, 108, 118, 40, 200, 183, 106, 66, 230, 76, 96, 63, 40, + 51, 48, 254, 58, 127, 108, 116, 50, 121, 113, 238, 109, 116, 6, 111, 124, 86, + 163, 66, 48, 251, 210, 162, 51, 22, 68, 122, 112, 101, 185, 157, 8, 91, 15, + 169, 222, 119, 176, 65, 226, 193, 78, 66, 181, 23, 224, 242, 71, 199, 134, + 177, 88, 125, 172, 228, 102, 99, 30, 141, 220, 81, 39, 170, 87, 132, 139, 119, + 69, 137, 20, 183, 89, 250, 180, 177, 217, 118, 200, 126, 136, 190, 97, 54, + 162, 5, 4, 21, 117, 157, 124, 66, 195, 101, 45, 232, 61, 199, 17, 30, 58, 233, + 204, 179, 123, 214, 53, 107, 50, 232, 111, 136, 236, 243, 194, 148, 78, 148, + 184, 31, 22, 228, 249, 70, 131, 29, 216, 101, 131, 168, 36, 100, 113, 148, 83, + 9, 233, 84, 167, 2, 138, 99, 244, 59, 202, 69, 8, 15, 142, 243, 153, 53, 118, + 28, 0, 26, 187, 59, 230, 227, 124, 78, 186, 250, 164, 200, 110, 21, 157, 234, + 174, 196, 47, 148, 149, 93, 27, 72, 222, 123, 37, 60, 175, 185, 63, 123, 145, + 75, 244, 139, 207, 25, 231, 129, 218, 101, 32, 220, 199, 77, 14, 192, 180, + 117, 71, 167, 153, 61, 165, 148, 242, 189, 19, 149, 209, 11, 252, 177, 90, + 143, 249, 119, 147, 203, 43, 108, 129, 90, 172, 65, 213, 167, 128, 77, 220, + 187, 225, 127, 73, 139, 111, 126, 250, 181, 7, 254, 55, 15, 28, 246, 239, 190, + 159, 35, 107, 252, 85, 70, 158, 15, 79, 51, 94, 167, 255, 96, 159, 182, 250, + 33, 252, 175, 246, 29, 136, 209, 199, 77, 51, 27, 182, 48, 0, 0, 0, 0, 73, 69, + 78, 68, 174, 66, 96, 130, +]); + +it("can return image info", async ({ expect }) => { + const resp = (await ( + await exports.default.fetch("https://example.com/", { + method: "POST", + body: new Blob([TINY_PNG]).stream(), + }) + ).json()) as ImageInfoResponse; + + expect(resp.format).toEqual("image/png"); +}); diff --git a/fixtures/vitest-pool-workers-examples/images/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/images/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/images/tsconfig.json b/fixtures/vitest-pool-workers-examples/images/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/images/vitest.config.ts b/fixtures/vitest-pool-workers-examples/images/vitest.config.ts new file mode 100644 index 0000000..4217062 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/images/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/images/wrangler.jsonc new file mode 100644 index 0000000..1d36b37 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/images/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "images", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "images": { + "binding": "IMAGES", + }, +} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/README.md b/fixtures/vitest-pool-workers-examples/kv-r2-caches/README.md new file mode 100644 index 0000000..5451091 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/README.md @@ -0,0 +1,8 @@ +# 📦 kv-r2-caches + +This Worker makes use of KV and R2 bindings, along with the Cache API. The Worker accepts `GET`/`PUT` requests to `/kv/...` and `/r2/...`. Request URLs are used as keys. `PUT` requests store their body at the corresponding key. `GET` requests read the value at the key. R2 reads are cached using the Cache API. + +| Test | Overview | +| ----------------------------- | ------------------------------------------- | +| [kv.test.ts](test/kv.test.ts) | Integration tests for KV endpoints | +| [r2.test.ts](test/r2.test.ts) | Integration and unit tests for R2 endpoints | diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/env.d.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/env.d.ts new file mode 100644 index 0000000..65522bb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./kv-r2-caches/src/env.d.ts -c ./kv-r2-caches/wrangler.jsonc --no-include-runtime` (hash: 7e0cf6d569a493341235c16658e7e4d0) +interface __BaseEnv_Env { + KV_NAMESPACE: KVNamespace; + R2_BUCKET: R2Bucket; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/helpers.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/helpers.ts new file mode 100644 index 0000000..9b97bc7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/helpers.ts @@ -0,0 +1,39 @@ +export async function handleKVRequest(request: Request, env: Env) { + if (request.method === "GET") { + const value = await env.KV_NAMESPACE.get(request.url, "stream"); + return new Response(value, { status: value === null ? 204 : 200 }); + } else if (request.method === "PUT") { + await env.KV_NAMESPACE.put(request.url, request.body ?? ""); + return new Response(null, { status: 204 }); + } else { + return new Response("Method Not Allowed", { status: 405 }); + } +} + +export async function handleR2Request( + request: Request, + env: Env, + ctx: ExecutionContext +) { + if (request.method === "GET") { + let response = await caches.default.match(request); + if (response !== undefined) return response; + + const object = await env.R2_BUCKET.get(request.url); + if (object === null) return new Response(null, { status: 204 }); + + const headers = new Headers(); + object.writeHttpMetadata(headers); + response = new Response(object.body, { headers }); + + ctx.waitUntil(caches.default.put(request, response.clone())); + return response; + } else if (request.method === "PUT") { + await env.R2_BUCKET.put(request.url, request.body, { + httpMetadata: request.headers, + }); + return new Response(null, { status: 204 }); + } else { + return new Response("Method Not Allowed", { status: 405 }); + } +} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/index.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/index.ts new file mode 100644 index 0000000..7242b1a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/index.ts @@ -0,0 +1,14 @@ +import { handleKVRequest, handleR2Request } from "./helpers"; + +export default >{ + fetch(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname.startsWith("/kv/")) { + return handleKVRequest(request, env); + } else if (url.pathname.startsWith("/r2/")) { + return handleR2Request(request, env, ctx); + } else { + return new Response("Not Found", { status: 404 }); + } + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/kv.test.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/kv.test.ts new file mode 100644 index 0000000..3dfc11e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/kv.test.ts @@ -0,0 +1,14 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("stores in KV namespace", async ({ expect }) => { + let response = await exports.default.fetch("https://example.com/kv/key", { + method: "PUT", + body: "value", + }); + expect(response.status).toBe(204); + + response = await exports.default.fetch("https://example.com/kv/key"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("value"); +}); diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/r2.test.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/r2.test.ts new file mode 100644 index 0000000..d229924 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/r2.test.ts @@ -0,0 +1,32 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { it } from "vitest"; +import { handleR2Request } from "../src/helpers"; // Note we can import any function + +it("stores in R2 bucket", async ({ expect }) => { + let response = await exports.default.fetch("https://example.com/r2/key", { + method: "PUT", + headers: { "Cache-Control": "max-age=3600" }, + body: "value", + }); + expect(response.status).toBe(204); + + const request = new Request("https://example.com/r2/key"); + let ctx = createExecutionContext(); + response = await handleR2Request(request, env, ctx); + await waitOnExecutionContext(ctx); // Wait for `caches.default.put()` + expect(response.status).toBe(200); + expect(response.headers.get("CF-Cache-Status")).toBe(null); + expect(await response.text()).toBe("value"); + + // Check 2nd request to the same resource is cached + ctx = createExecutionContext(); + response = await handleR2Request(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(200); + expect(response.headers.get("CF-Cache-Status")).toBe("HIT"); + expect(await response.text()).toBe("value"); +}); diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/tsconfig.json b/fixtures/vitest-pool-workers-examples/kv-r2-caches/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/vitest.config.ts b/fixtures/vitest-pool-workers-examples/kv-r2-caches/vitest.config.ts new file mode 100644 index 0000000..4217062 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/kv-r2-caches/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/kv-r2-caches/wrangler.jsonc new file mode 100644 index 0000000..43d1d43 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/kv-r2-caches/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "name": "kv-r2-caches", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "kv_namespaces": [ + { + "binding": "KV_NAMESPACE", + "id": "00000000000000000000000000000000", + }, + ], + "r2_buckets": [ + { + "binding": "R2_BUCKET", + "bucket_name": "bucket", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/misc/README.md b/fixtures/vitest-pool-workers-examples/misc/README.md new file mode 100644 index 0000000..24cbb33 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/README.md @@ -0,0 +1,3 @@ +# 🤷 misc + +We run all the tests in `vitest-pool-workers-examples` on every change to `workers-sdk`. This directory contains a bunch of tests to validate assorted Vitest features that aren't tested elsewhere. diff --git a/fixtures/vitest-pool-workers-examples/misc/global-setup.ts b/fixtures/vitest-pool-workers-examples/misc/global-setup.ts new file mode 100644 index 0000000..3ebff2e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/global-setup.ts @@ -0,0 +1,12 @@ +// Regression test for #9957: In v3, `provide` data was sent via HTTP headers +// (~8 KB limit). Large payloads caused silent failures. v4 sends provide data +// through WebSocket messages (32 MiB limit). +import type { TestProject } from "vitest/node"; + +export default function ({ provide }: TestProject) { + // No ProvidedContext declaration for this key, so cast is needed + (provide as (key: string, value: unknown) => void)( + "largePayload", + "x".repeat(50_000) + ); +} diff --git a/fixtures/vitest-pool-workers-examples/misc/public/test.txt b/fixtures/vitest-pool-workers-examples/misc/public/test.txt new file mode 100644 index 0000000..b45ef6f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/public/test.txt @@ -0,0 +1 @@ +Hello, World! \ No newline at end of file diff --git a/fixtures/vitest-pool-workers-examples/misc/src/other-worker.mjs b/fixtures/vitest-pool-workers-examples/misc/src/other-worker.mjs new file mode 100644 index 0000000..8cc2d57 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/src/other-worker.mjs @@ -0,0 +1,5 @@ +export class OtherObject { + async fetch(request) { + return new Response("OtherObject body"); + } +} diff --git a/fixtures/vitest-pool-workers-examples/misc/test/bare-specifiers.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/bare-specifiers.test.ts new file mode 100644 index 0000000..6fbeea5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/bare-specifiers.test.ts @@ -0,0 +1,26 @@ +// Regression test for #6214: bare Node.js module specifiers (without `node:` +// prefix) should resolve. The `module-fallback.ts` `viteResolve()` codepath +// prepends `node:` when a bare specifier isn't found in workerd built-ins. +import { describe, it } from "vitest"; + +describe("bare Node module specifiers", () => { + it("resolves 'url' without node: prefix", async ({ expect }) => { + const urlModule = await import("url"); + expect(urlModule.URL).toBeDefined(); + expect(new urlModule.URL("https://example.com").hostname).toBe( + "example.com" + ); + }); + + it("resolves 'path' without node: prefix", async ({ expect }) => { + const pathModule = await import("path"); + expect(pathModule.join).toBeDefined(); + expect(pathModule.join("/foo", "bar")).toBe("/foo/bar"); + }); + + it("resolves 'buffer' without node: prefix", async ({ expect }) => { + const bufferModule = await import("buffer"); + expect(bufferModule.Buffer).toBeDefined(); + expect(bufferModule.Buffer.from("hello").toString()).toBe("hello"); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/defines.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/defines.test.ts new file mode 100644 index 0000000..9eb8e64 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/defines.test.ts @@ -0,0 +1,13 @@ +import { it } from "vitest"; + +it("replaces defines from wrangler.toml", async ({ expect }) => { + expect(WRANGLER_DEFINED_THING).toBe("thing"); + expect(WRANGLER_NESTED.DEFINED.THING).toStrictEqual([1, 2, 3]); + expect(WRANGLER_NESTED.DEFINED.THING).toBe(WRANGLER_NESTED.DEFINED.THING); +}); + +it("replaces defines from vitest.config.mts", async ({ expect }) => { + expect(CONFIG_DEFINED_THING).toBe("thing"); + expect(CONFIG_NESTED.DEFINED.THING).toStrictEqual([1, 2, 3]); + // Note that, unlike ESBuild, Oxc does not share object references when using `define` (https://oxc.rs/docs/guide/usage/transformer/global-variable-replacement#define) +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/durable-objects.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/durable-objects.test.ts new file mode 100644 index 0000000..10f0fd1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/durable-objects.test.ts @@ -0,0 +1,23 @@ +import { + listDurableObjectIds, + runDurableObjectAlarm, + runInDurableObject, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +it("uses other object", async ({ expect }) => { + const id = env.OTHER_OBJECT.idFromName("other-test"); + const stub = env.OTHER_OBJECT.get(id); + const response = await stub.fetch("http://x"); + expect(await response.text()).toBe("OtherObject body"); + + // Check can only use run in helpers for same-isolate classes... + await expect(runInDurableObject(stub, () => {})).rejects.toThrow(); + await expect(runDurableObjectAlarm(stub)).rejects.toThrow(); + + // ...but can list IDs for any class + const ids = await listDurableObjectIds(env.OTHER_OBJECT); + expect(ids.length).toBe(1); + expect(ids[0].equals(id)).toBe(true); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts b/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts new file mode 100644 index 0000000..9e4376d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts @@ -0,0 +1,7 @@ +declare namespace Cloudflare { + interface Env { + ASSETS: Fetcher; + KV_NAMESPACE: KVNamespace; + OTHER_OBJECT: DurableObjectNamespace; + } +} diff --git a/fixtures/vitest-pool-workers-examples/misc/test/fake-timers.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/fake-timers.test.ts new file mode 100644 index 0000000..1ae970c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/fake-timers.test.ts @@ -0,0 +1,23 @@ +import { afterEach, beforeEach, it, vi } from "vitest"; + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +it("fake system time", ({ expect }) => { + const date = new Date(2023, 0, 1); + vi.setSystemTime(date); + expect(new Date()).toMatchInlineSnapshot(`2023-01-01T00:00:00.000Z`); +}); + +it("advances fake time", ({ expect }) => { + const fn = vi.fn(() => {}); + setTimeout(fn, 1000); + vi.advanceTimersByTime(500); + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalled(); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/global.d.ts b/fixtures/vitest-pool-workers-examples/misc/test/global.d.ts new file mode 100644 index 0000000..60644ea --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/global.d.ts @@ -0,0 +1,9 @@ +declare global { + const WRANGLER_DEFINED_THING: string; + const WRANGLER_NESTED: { DEFINED: { THING: boolean } }; + + const CONFIG_DEFINED_THING: string; + const CONFIG_NESTED: { DEFINED: { THING: boolean } }; +} + +export {}; diff --git a/fixtures/vitest-pool-workers-examples/misc/test/large-provide.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/large-provide.test.ts new file mode 100644 index 0000000..3e69986 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/large-provide.test.ts @@ -0,0 +1,12 @@ +// Regression test for #9957: In v3, `provide` data was sent via HTTP headers +// (~8 KB limit). v4 sends provide data through WebSocket messages (32 MiB +// limit). This test injects a 50 KB string provided by global-setup.ts. +import { inject, it } from "vitest"; + +it("receives 50 KB of provided data without truncation", ({ expect }) => { + const data = inject("largePayload" as never); + expect(data).toBeDefined(); + expect(typeof data).toBe("string"); + expect((data as string).length).toBe(50_000); + expect(data).toBe("x".repeat(50_000)); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/module-mocking.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/module-mocking.test.ts new file mode 100644 index 0000000..09c0511 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/module-mocking.test.ts @@ -0,0 +1,14 @@ +import { jwtVerify, JWTVerifyResult } from "jose"; +import { it, vi } from "vitest"; + +vi.mock("jose", () => { + const jwtVerify = async (): Promise> => { + return { payload: { mock: true }, protectedHeader: { alg: "" } }; + }; + return { jwtVerify }; +}); + +it("uses mocked module", async ({ expect }) => { + const result = await jwtVerify("", new Uint8Array()); + expect(result.payload).toStrictEqual({ mock: true }); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/msw.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/msw.test.ts new file mode 100644 index 0000000..aac8a34 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/msw.test.ts @@ -0,0 +1,61 @@ +import { http, HttpResponse } from "msw"; +import { expect, it } from "vitest"; +import { server } from "./server"; + +it("falls through to global fetch() if unmatched", async () => { + server.use( + http.get( + "https://example.com", + () => { + return HttpResponse.text("body"); + }, + { once: true } + ) + ); + + let response = await fetch("https://example.com"); + expect(response.url).toEqual("https://example.com/"); + expect(await response.text()).toBe("body"); + + response = await fetch("https://example.com/bad"); + expect(response.url).toEqual("https://example.com/bad"); + expect(await response.text()).toBe("fallthrough:GET https://example.com/bad"); +}); + +it("intercepts URLs with query parameters with repeated keys", async () => { + server.use( + http.get( + "https://example.com/foo?key=value", + () => { + return HttpResponse.text("foo"); + }, + { once: true } + ), + http.get( + "https://example.com/bar?a=1&a=2", + () => { + return HttpResponse.text("bar"); + }, + { once: true } + ), + http.get( + "https://example.com/baz?key1=a&key2=c&key1=b", + () => { + return HttpResponse.text("baz"); + }, + { once: true } + ) + ); + + let response1 = await fetch("https://example.com/foo?key=value"); + expect(response1.url).toEqual("https://example.com/foo?key=value"); + expect(await response1.text()).toBe("foo"); + + let response2 = await fetch("https://example.com/bar?a=1&a=2"); + expect(response2.url).toEqual("https://example.com/bar?a=1&a=2"); + expect(await response2.text()).toBe("bar"); + + let response3 = await fetch("https://example.com/baz?key1=a&key2=c&key1=b"); + expect(response3.url).toEqual("https://example.com/baz?key1=a&key2=c&key1=b"); + expect(await response3.text()).toBe("baz"); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/nodejs.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/nodejs.test.ts new file mode 100644 index 0000000..f3c5132 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/nodejs.test.ts @@ -0,0 +1,8 @@ +import { it } from "vitest"; + +it("runs in Node.js compatibility mode", ({ expect }) => { + expect(typeof process).toBe("object"); + expect(process.versions).toBeDefined(); + expect(process.versions.node).toBeDefined(); + expect(typeof Buffer).toBe("function"); +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts new file mode 100644 index 0000000..b07f00d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts @@ -0,0 +1,183 @@ +import { + createPagesEventContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it, onTestFinished } from "vitest"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `createPagesEventContext()`. +const IncomingRequest = Request; + +type BareFunction = PagesFunction>; + +it("can consume body in middleware and in next request", async ({ expect }) => { + const fn: BareFunction = async (ctx) => { + const requestText = await ctx.request.text(); + const nextResponse = await ctx.next(); + const nextResponseText = await nextResponse.text(); + return Response.json({ requestText, nextResponseText }); + }; + const request = new IncomingRequest("https://example.com", { + method: "POST", + body: "body", + }); + const ctx = createPagesEventContext({ + request, + async next(nextRequest) { + const nextRequestText = await nextRequest.text(); + return new Response(nextRequestText); + }, + }); + const response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.json()).toStrictEqual({ + requestText: "body", + nextResponseText: "body", + }); +}); + +it("can rewrite to absolute and relative urls in next", async ({ expect }) => { + const fn: BareFunction = async (ctx) => { + const { pathname } = new URL(ctx.request.url); + if (pathname === "/absolute") { + return ctx.next("https://example.com/new-absolute", { method: "PUT" }); + } else if (pathname === "/relative/") { + return ctx.next("./new", { method: "PATCH" }); + } else { + return new Response(null, { status: 404 }); + } + }; + + // Check with absolute rewrite + let request = new IncomingRequest("https://example.com/absolute"); + let ctx = createPagesEventContext({ + request, + async next(nextRequest) { + return new Response(`next:${nextRequest.method} ${nextRequest.url}`); + }, + }); + let response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe( + "next:PUT https://example.com/new-absolute" + ); + + // Check with relative rewrite + request = new IncomingRequest("https://example.com/relative/"); + ctx = createPagesEventContext({ + request, + async next(nextRequest) { + return new Response(`next:${nextRequest.method} ${nextRequest.url}`); + }, + }); + response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe( + "next:PATCH https://example.com/relative/new" + ); +}); + +it("requires next property to call next()", async ({ expect }) => { + const fn: BareFunction = (ctx) => ctx.next(); + const request = new IncomingRequest("https://example.com"); + const ctx = createPagesEventContext({ request }); + await expect(fn(ctx)).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot call \`EventContext#next()\` without including \`next\` property in 2nd argument to \`createPagesEventContext()\`]` + ); +}); + +it("requires ASSETS service binding", async ({ expect }) => { + let originalASSETS = env.ASSETS; + onTestFinished(() => { + env.ASSETS = originalASSETS; + }); + delete (env as Partial).ASSETS; + + const request = new IncomingRequest("https://example.com", { + method: "POST", + body: "body", + }); + expect(() => + createPagesEventContext({ request }) + ).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot call \`createPagesEventContext()\` without defining \`ASSETS\` service binding]` + ); +}); + +it("waits for waitUntil()ed promises", async ({ expect }) => { + const fn: BareFunction = (ctx) => { + ctx.waitUntil(ctx.env.KV_NAMESPACE.put("key", "value")); + return new Response(); + }; + const request = new IncomingRequest("https://example.com"); + const ctx = createPagesEventContext({ request }); + await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await env.KV_NAMESPACE.get("key")).toBe("value"); +}); + +it("correctly types parameters", async ({ expect }) => { + const request = new IncomingRequest("https://example.com"); + + // Check no params and no data required + { + type Fn = PagesFunction>; + createPagesEventContext({ request }); + createPagesEventContext({ request, params: {} }); + // @ts-expect-error no params required + createPagesEventContext({ request, params: { a: "1" } }); + createPagesEventContext({ request, data: {} }); + // @ts-expect-error no data required + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check no params but data required + { + type Fn = PagesFunction; + // @ts-expect-error data required + createPagesEventContext({ request }); + // @ts-expect-error data required + createPagesEventContext({ request, params: {} }); + // @ts-expect-error no params but data required + createPagesEventContext({ request, params: { a: "1" } }); + // @ts-expect-error data required + createPagesEventContext({ request, data: {} }); + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check no data but params required + { + type Fn = PagesFunction>; + // @ts-expect-error params required + createPagesEventContext({ request }); + // @ts-expect-error params required + createPagesEventContext({ request, params: {} }); + createPagesEventContext({ request, params: { a: ["1"] } }); + // @ts-expect-error no data but params required + createPagesEventContext({ request, data: {} }); + // @ts-expect-error no data but params required + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check params and data required + { + type Fn = PagesFunction; + // @ts-expect-error params required + createPagesEventContext({ request }); + // @ts-expect-error params required + createPagesEventContext({ request, params: {} }); + // @ts-expect-error data required + createPagesEventContext({ request, params: { a: "1" } }); + // @ts-expect-error params required + createPagesEventContext({ request, data: {} }); + // @ts-expect-error params required + createPagesEventContext({ request, data: { b: "1" } }); + createPagesEventContext({ + request, + params: { a: "1" }, + data: { b: "1" }, + }); + } +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/server.ts b/fixtures/vitest-pool-workers-examples/misc/test/server.ts new file mode 100644 index 0000000..bd0bda5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/server.ts @@ -0,0 +1,3 @@ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/setup.ts b/fixtures/vitest-pool-workers-examples/misc/test/setup.ts new file mode 100644 index 0000000..f37c96e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/setup.ts @@ -0,0 +1,10 @@ +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./server"; + +beforeAll(() => + server.listen({ + onUnhandledRequest: "bypass", + }) +); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/fixtures/vitest-pool-workers-examples/misc/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/misc/test/tsconfig.json new file mode 100644 index 0000000..40d2455 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/misc/tsconfig.json b/fixtures/vitest-pool-workers-examples/misc/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/misc/vitest.assets.config.ts b/fixtures/vitest-pool-workers-examples/misc/vitest.assets.config.ts new file mode 100644 index 0000000..597a234 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/vitest.assets.config.ts @@ -0,0 +1,27 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + assets: { + directory: "./public", + binding: "ASSETS", + }, + }, + wrangler: { + configPath: "./wrangler.assets.jsonc", + }, + }), + ], + + test: { + name: "misc-assets", + include: ["test/assets.test.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts b/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts new file mode 100644 index 0000000..a249478 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts @@ -0,0 +1,46 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { Response } from "miniflare"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + kvNamespaces: ["KV_NAMESPACE"], + outboundService(request) { + return new Response(`fallthrough:${request.method} ${request.url}`); + }, + serviceBindings: { + ASSETS(request) { + return new Response(`assets:${request.method} ${request.url}`); + }, + }, + workers: [ + { + name: "other", + modules: true, + scriptPath: "./src/other-worker.mjs", + }, + ], + }, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + + define: { + CONFIG_DEFINED_THING: '"thing"', + "CONFIG_NESTED.DEFINED.THING": "[1,2,3]", + }, + + test: { + exclude: ["test/assets.test.ts", "test/nodejs.test.ts"], + globalSetup: ["./global-setup.ts"], + setupFiles: ["test/setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/misc/vitest.nodejs.config.ts b/fixtures/vitest-pool-workers-examples/misc/vitest.nodejs.config.ts new file mode 100644 index 0000000..4c33e22 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/vitest.nodejs.config.ts @@ -0,0 +1,21 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.nodejs.jsonc", + }, + }), + ], + + test: { + name: "misc-nodejs", + include: ["test/nodejs.test.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/misc/wrangler.assets.jsonc b/fixtures/vitest-pool-workers-examples/misc/wrangler.assets.jsonc new file mode 100644 index 0000000..87f70bd --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/wrangler.assets.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "../node_modules/wrangler/config-schema.json", + "compatibility_date": "2024-01-01", + "assets": { + // This will be overridden by the assets config in vitest.config.ts + // See https://github.com/cloudflare/workers-sdk/issues/9130 + "directory": "./does-not-exist", + "binding": "ASSETS", + }, +} diff --git a/fixtures/vitest-pool-workers-examples/misc/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/misc/wrangler.jsonc new file mode 100644 index 0000000..093c15a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "../node_modules/wrangler/config-schema.json", + // don't provide compatibility_date so that vitest will infer the latest one + "define": { + "WRANGLER_DEFINED_THING": "\"thing\"", + "WRANGLER_NESTED.DEFINED.THING": "[1,2,3]", + }, + "durable_objects": { + "bindings": [ + { + "name": "OTHER_OBJECT", + "class_name": "OtherObject", + "script_name": "other", + }, + ], + }, +} diff --git a/fixtures/vitest-pool-workers-examples/misc/wrangler.nodejs.jsonc b/fixtures/vitest-pool-workers-examples/misc/wrangler.nodejs.jsonc new file mode 100644 index 0000000..4f45603 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/wrangler.nodejs.jsonc @@ -0,0 +1,5 @@ +{ + "$schema": "../node_modules/wrangler/config-schema.json", + // don't provide compatibility_date so that vitest will infer the latest one + "compatibility_flags": ["nodejs_compat"], +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/README.md b/fixtures/vitest-pool-workers-examples/module-resolution/README.md new file mode 100644 index 0000000..a2c8672 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/README.md @@ -0,0 +1,36 @@ +# ⚡ module-resolution + +This fixture demonstrates that the Vitest integration correctly resolves modules, including: + +- A CommonJS package that requires a directory rather than a specific file. +- An ESM dependency that imports `*.wasm?module` from the same directory. +- A package without a main entrypoint or with browser field mapping, handled via [Dependency Pre-Bundling](#dependency-pre-bundling). + +## Dependency Pre-Bundling + +[Dependency Pre-Bundling](https://vite.dev/guide/dep-pre-bundling) is a Vite feature that converts dependencies shipped as CommonJS or UMD into ESM. If you encounter module resolution issues—such as: `Error: Cannot use require() to import an ES Module` or `Error: No such module`—you can pre-bundle these dependencies using the [deps.optimizer](https://vitest.dev/config/#deps-optimizer) option: + +```ts +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + test: { + deps: { + optimizer: { + ssr: { + enabled: true, + include: ["your-package-name"], + }, + }, + }, + }, +}); +``` + +See our [vitest config](./vitest.config.ts) for an example of how we pre-bundled `discord-api-types/v10` and `@microlabs/otel-cf-workers`. diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/src/env.d.ts b/fixtures/vitest-pool-workers-examples/module-resolution/src/env.d.ts new file mode 100644 index 0000000..a91c854 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/src/env.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./module-resolution/src/env.d.ts -c ./module-resolution/wrangler.jsonc --no-include-runtime` (hash: 0c8cd5ae12176ebab8337cbfef98b516) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/src/index.ts b/fixtures/vitest-pool-workers-examples/module-resolution/src/index.ts new file mode 100644 index 0000000..ba0c8cd --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/src/index.ts @@ -0,0 +1,41 @@ +import * as bAuthStripe from "@better-auth/stripe"; +// Testing dependency with browser field mapping +// @see https://github.com/cloudflare/workers-sdk/issues/6581 +import * as otel from "@microlabs/otel-cf-workers"; +import * as bAuth from "better-auth"; +// Testing dependency without a main entrypoint +// @see https://github.com/cloudflare/workers-sdk/issues/6591 +import * as discord from "discord-api-types/v10"; +import * as jose from "jose"; +import nunjucks from "nunjucks"; +import Stripe from "stripe"; +import { Toucan } from "toucan-js"; + +export default { + async fetch(): Promise { + // Verify that we're able to import & create the toucan class. This is a library where `"module": "..."` is set, and `"type": "module"` is not + const _ = new Toucan({ + dsn: "https://foo@sentry.com/123456", + }); + const a = Stripe; + + nunjucks.configure({ autoescape: true }); + + console.log( + nunjucks.renderString("Hello {{ username }}", { username: "James" }) + ); + + // Make sure none of the imports are tree shaken + return new Response( + JSON.stringify({ + discord, + otel, + jose, + bAuth, + bAuthStripe, + a, + n: nunjucks.renderString("Hello {{ username }}", { username: "James" }), + }) + ); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/src/test.sql b/fixtures/vitest-pool-workers-examples/module-resolution/src/test.sql new file mode 100644 index 0000000..c0e59fb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/src/test.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE id = 1; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/module-resolution/src/tsconfig.json new file mode 100644 index 0000000..b9d513a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"], + "compilerOptions": { + // In this fixture, we're including the toucan-js library, which references `@cloudflare/workers-types`, whereas the rest of the fixture is referencing + // `@cloudflare/workers-types/experimental`. This causes a conflict in types defined, so we need to skip the lib check specifically in this directory + "skipLibCheck": true + } +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/test/index.d.ts b/fixtures/vitest-pool-workers-examples/module-resolution/test/index.d.ts new file mode 100644 index 0000000..f20a628 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/test/index.d.ts @@ -0,0 +1,10 @@ +declare module "ext-dep" { + var x: number; + export default x; +} + +// .sql files are loaded as Text modules by default in wrangler +declare module "*.sql" { + const content: string; + export default content; +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/test/index.spec.ts b/fixtures/vitest-pool-workers-examples/module-resolution/test/index.spec.ts new file mode 100644 index 0000000..105ad29 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/test/index.spec.ts @@ -0,0 +1,63 @@ +import { instrument } from "@microlabs/otel-cf-workers"; +import addFromCjsWasmModuleDep from "cjs-wasm-module-dep"; +import { exports } from "cloudflare:workers"; +import { Utils } from "discord-api-types/v10"; +import { value as esmDepValue } from "esm-dep"; +import dep from "ext-dep"; +import mime from "mime-types"; +import { assert, describe, expect, test } from "vitest"; +import addFromWasmModuleDep from "wasm-module-dep"; +import worker from "../src/index"; +import sqlPlain from "../src/test.sql"; +import sqlRaw from "../src/test.sql?raw"; + +describe("test", () => { + test("resolves commonjs directory dependencies correctly", async () => { + assert.equal(dep, 123); + }); + + test("resolves dependency without a default entrypoint", async () => { + assert.isFunction(Utils.isDMInteraction); + }); + + test("resolves a dependency importing .wasm?module", async () => { + assert.equal(await addFromWasmModuleDep(2, 3), 5); + }); + + test("resolves a CJS dependency requiring .wasm?module", async () => { + assert.equal(await addFromCjsWasmModuleDep(2, 3), 5); + }); + + test("resolves dependency with mapping on the browser field", async () => { + assert.isFunction(instrument); + }); + + test("can use toucan-js (integration)", async () => { + expect((await exports.default.fetch("http://example.com")).status).toBe( + 200 + ); + }); + + test("can use toucan-js (unit)", async () => { + const response = await worker.fetch(); + expect(response.status).toBe(200); + }); + + // Regression test for https://github.com/cloudflare/workers-sdk/issues/12049 + // Vite query parameters like ?raw should be handled by Vite, not module rules + test("resolves file with ?raw query parameter", async () => { + assert.equal(sqlRaw, sqlPlain); + }); + + // Verify CommonJS require() of JSON files works + test("resolves dependency that requires JSON files", async () => { + assert.equal(mime.lookup("test.html"), "text/html"); + }); + + // Regression test for https://github.com/cloudflare/workers-sdk/issues/12022 + // ESM imports with extensionless specifiers should be resolved by Vite, not + // by the module fallback's extension-probing loop (which is only for require()). + test("resolves ESM dependency with extensionless internal import", async () => { + assert.equal(esmDepValue, 456); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/module-resolution/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/tsconfig.json b/fixtures/vitest-pool-workers-examples/module-resolution/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/add.wasm b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/add.wasm new file mode 100644 index 0000000..fa370ae Binary files /dev/null and b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/add.wasm differ diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.d.ts b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.d.ts new file mode 100644 index 0000000..6094be8 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.d.ts @@ -0,0 +1,3 @@ +declare const add: (a: number, b: number) => Promise; + +export = add; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.js b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.js new file mode 100644 index 0000000..dbfd00d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/index.js @@ -0,0 +1,5 @@ +module.exports = async function add(a, b) { + const { default: addModule } = require("./add.wasm?module"); + const addInstance = new WebAssembly.Instance(addModule); + return addInstance.exports.add(a, b); +}; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/package.json b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/package.json new file mode 100644 index 0000000..edc60dc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/cjs-wasm-module-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-wasm-module-dep", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.d.mts b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.d.mts new file mode 100644 index 0000000..052dbfe --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.d.mts @@ -0,0 +1 @@ +export declare const value: number; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.mjs b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.mjs new file mode 100644 index 0000000..a315aa5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/index.mjs @@ -0,0 +1,5 @@ +// Intentionally extensionless — valid in bundler/Vite contexts but technically +// requires explicit extensions in strict ESM. The module fallback service must +// NOT add extensions for `import` (only for `require()`); it should defer to +// Vite's resolver for this. +export { value } from "./value"; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/package.json b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/package.json new file mode 100644 index 0000000..791e24f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "esm-dep", + "type": "module", + "main": "index.mjs" +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/value.mjs b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/value.mjs new file mode 100644 index 0000000..851a1e3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/esm-dep/value.mjs @@ -0,0 +1 @@ +export const value = 456; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/index.js b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/index.js new file mode 100644 index 0000000..901fd81 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/index.js @@ -0,0 +1 @@ +module.exports = require("./subfolder"); diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/package.json b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/package.json new file mode 100644 index 0000000..2a833aa --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "ext-dep", + "type": "commonjs", + "main": "index.js" +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/subfolder/index.js b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/subfolder/index.js new file mode 100644 index 0000000..1918562 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/ext-dep/subfolder/index.js @@ -0,0 +1 @@ +module.exports = 123; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/add.wasm b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/add.wasm new file mode 100644 index 0000000..fa370ae Binary files /dev/null and b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/add.wasm differ diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.d.mts b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.d.mts new file mode 100644 index 0000000..1930b8e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.d.mts @@ -0,0 +1,3 @@ +declare const add: (a: number, b: number) => Promise; + +export default add; diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.mjs b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.mjs new file mode 100644 index 0000000..d91f94a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/index.mjs @@ -0,0 +1,7 @@ +export default async function add(a, b) { + const { default: addModule } = await import( + /* @vite-ignore */ "./add.wasm?module" + ); + const addInstance = new WebAssembly.Instance(addModule); + return addInstance.exports.add(a, b); +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/package.json b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/package.json new file mode 100644 index 0000000..17398c1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vendor/wasm-module-dep/package.json @@ -0,0 +1,6 @@ +{ + "name": "wasm-module-dep", + "type": "module", + "main": "index.mjs", + "types": "index.d.mts" +} diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/vitest.config.ts b/fixtures/vitest-pool-workers-examples/module-resolution/vitest.config.ts new file mode 100644 index 0000000..d9a889a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/module-resolution/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/module-resolution/wrangler.jsonc new file mode 100644 index 0000000..2c5be6a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/module-resolution/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "module-resolution", + "main": "src/index.ts", + "compatibility_date": "2025-12-13", + "compatibility_flags": ["nodejs_compat"], + // don't provide compatibility_date so that vitest will infer the latest one +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/README.md b/fixtures/vitest-pool-workers-examples/multiple-workers/README.md new file mode 100644 index 0000000..7619eae --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/README.md @@ -0,0 +1,13 @@ +# 🔌 multiple-workers + +This example uses multiple Workers: + +- `api-service`: entrypoint to the project, forwards requests on to other services as needed +- `auth-service`: handles signing and verifying JWTs +- `database-service`: handles reading/writing values from a KV namespace + +In a real project, only the `api-service` would be publicly routable. The `auth-service` sends request to an external endpoint to login and sign JWTs. This endpoint is mocked in tests using the `outboundService` Miniflare option. The `database-service` assumes the user has been authenticated and allows reads/writes to any key. + +| Test | Overview | +| ----------------------------------------------- | --------------------------------- | +| [integration.test.ts](test/integration.test.ts) | Integration tests using `exports` | diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/env.d.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/env.d.ts new file mode 100644 index 0000000..03583ec --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multiple-workers/api-service/src/env.d.ts -c ./multiple-workers/api-service/wrangler.jsonc --no-include-runtime` (hash: b9086500665f32aa0533c30a22adce89) +interface __BaseEnv_Env { + AUTH_SERVICE: Fetcher /* auth-service */; + DATABASE_SERVICE: Fetcher /* database-service */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/index.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/index.ts new file mode 100644 index 0000000..7ffa915 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/index.ts @@ -0,0 +1,23 @@ +export default >{ + async fetch(request, env, ctx) { + const url = new URL(request.url); + // Forward login requests to the auth service + if (url.pathname === "/login") return env.AUTH_SERVICE.fetch(request); + + // Verify the user's token with the auth service... + const verifyResponse = await env.AUTH_SERVICE.fetch( + "http://placeholder/verify", + { method: "POST", headers: request.headers } + ); + + if (!verifyResponse.ok) return verifyResponse; + + // ...then prefix the path with the username and forward to database service + const payload = await verifyResponse.json<{ + "urn:example:username": string; + }>(); + const username = payload["urn:example:username"]; + url.pathname = `/${username}${url.pathname}`; + return env.DATABASE_SERVICE.fetch(url, request); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/tsconfig.json new file mode 100644 index 0000000..75cdccc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/wrangler.jsonc new file mode 100644 index 0000000..3c22e07 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/api-service/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + "name": "api-service", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "services": [ + { + "binding": "AUTH_SERVICE", + "service": "auth-service", + }, + { + "binding": "DATABASE_SERVICE", + "service": "database-service", + }, + ], + "tail_consumers": [ + { "service": "this-tail-does-not-exist" }, + { "service": "tail-consumer" }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/env.d.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/env.d.ts new file mode 100644 index 0000000..16d5dda --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multiple-workers/auth-service/src/env.d.ts -c ./multiple-workers/auth-service/wrangler.jsonc --no-include-runtime` (hash: 371cc12bcdcb91caf0579ce1fe3dcf6b) +interface __BaseEnv_Env { + AUTH_PUBLIC_KEY: string; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/index.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/index.ts new file mode 100644 index 0000000..e9678be --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/index.ts @@ -0,0 +1,45 @@ +import { importSPKI, jwtVerify } from "jose"; + +export default >{ + async fetch(request, env, ctx) { + if (request.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + + const { pathname } = new URL(request.url); + if (pathname === "/login") { + // Forward login request to upstream service mocked in test. + const formData = await request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + if (typeof username !== "string" || typeof password !== "string") { + return new Response("Bad Request", { status: 400 }); + } + return fetch("https://example.com/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + } else if (pathname === "/verify") { + // Verify token with public key + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401 }); + } + const token = authHeader.substring("Bearer ".length); + + const alg = "RS256"; + const publicKey = await importSPKI(env.AUTH_PUBLIC_KEY, alg); + try { + const { payload } = await jwtVerify(token, publicKey, { + issuer: "urn:example:issuer", + audience: "urn:example:audience", + }); + return Response.json(payload); + } catch { + return new Response("Unauthorized", { status: 401 }); + } + } else { + return new Response("Not Found", { status: 404 }); + } + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/tsconfig.json new file mode 100644 index 0000000..75cdccc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/wrangler.jsonc new file mode 100644 index 0000000..ed2f14a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/auth-service/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "auth-service", + "main": "src/index.ts", + // compatibility_date is required so we can run `wrangler build` + "compatibility_date": "2024-01-01", + /* + `AUTH_PUBLIC_KEY` would be a secret created with `wrangler secret put`, and + stored in `.dev.vars` during development + */ + "secrets": { + "required": ["AUTH_PUBLIC_KEY"], + }, +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/env.d.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/env.d.ts new file mode 100644 index 0000000..aec38ae --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./multiple-workers/database-service/src/env.d.ts -c ./multiple-workers/database-service/wrangler.jsonc --no-include-runtime` (hash: 836c8ec92bcd45633fe61ef63340d336) +interface __BaseEnv_Env { + KV_NAMESPACE: KVNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/index.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/index.ts new file mode 100644 index 0000000..c05f2db --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/index.ts @@ -0,0 +1,14 @@ +export default >{ + async fetch(request, env, ctx) { + const { pathname } = new URL(request.url); + if (request.method === "GET") { + const value = await env.KV_NAMESPACE.get(pathname, "stream"); + return new Response(value, { status: value === null ? 204 : 200 }); + } else if (request.method === "PUT") { + await env.KV_NAMESPACE.put(pathname, request.body ?? ""); + return new Response(null, { status: 204 }); + } else { + return new Response("Method Not Allowed", { status: 405 }); + } + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/tsconfig.json new file mode 100644 index 0000000..75cdccc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/wrangler.jsonc new file mode 100644 index 0000000..a37f625 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/database-service/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "database-service", + "main": "src/index.ts", + // compatibility_date is required so we can run `wrangler build` + "compatibility_date": "2024-01-01", + "kv_namespaces": [ + { + "binding": "KV_NAMESPACE", + "id": "00000000000000000000000000000000", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/global-setup.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/global-setup.ts new file mode 100644 index 0000000..5d82857 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/global-setup.ts @@ -0,0 +1,21 @@ +import childProcess from "node:child_process"; +import path from "node:path"; + +// Global setup runs inside Node.js, not `workerd` +export default function () { + // Build `api-service`'s dependencies + + let label = "Built multiple-workers auth-service"; + console.time(label); + childProcess.execSync("wrangler build", { + cwd: path.join(__dirname, "auth-service"), + }); + console.timeEnd(label); + + label = "Built multiple-workers database-service"; + console.time(label); + childProcess.execSync("wrangler build", { + cwd: path.join(__dirname, "database-service"), + }); + console.timeEnd(label); +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/test/env.d.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/test/env.d.ts new file mode 100644 index 0000000..20f2ff0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/test/env.d.ts @@ -0,0 +1,5 @@ +declare namespace Cloudflare { + interface Env { + TEST_AUTH_PUBLIC_KEY: string; // Defined in `vitest.config.mts` + } +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/test/integration.test.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/test/integration.test.ts new file mode 100644 index 0000000..83aacac --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/test/integration.test.ts @@ -0,0 +1,65 @@ +import { env, exports } from "cloudflare:workers"; +import { importSPKI, jwtVerify } from "jose"; // Check importing external module +import { it } from "vitest"; +import type { ExpectStatic } from "vitest"; + +async function login( + username: string, + password: string, + expect: ExpectStatic +): Promise { + const formData = new FormData(); + formData.set("username", username); + formData.set("password", password); + const response = await exports.default.fetch("https://example.com/login", { + method: "POST", + body: formData, + }); + expect(response.status).toBe(200); + const { token } = await response.json<{ token: string }>(); + expect(token).toMatch(/^ey/); + return token; +} + +it("logs in and generates token for user", async ({ expect }) => { + // Login and get token + const token = await login("admin", "lovelace", expect); + + // Verify token is valid + const alg = "RS256"; + const publicKey = await importSPKI(env.TEST_AUTH_PUBLIC_KEY, alg); + const { payload } = await jwtVerify(token, publicKey, { + issuer: "urn:example:issuer", + audience: "urn:example:audience", + }); + expect(payload).toStrictEqual({ + "urn:example:username": "admin", + iss: "urn:example:issuer", + aud: "urn:example:audience", + iat: expect.any(Number), + exp: expect.any(Number), + }); +}); + +it("stores in user's database", async ({ expect }) => { + // Login and get token + const token = await login("admin", "lovelace", expect); + + // Read and write from the database + let response = await exports.default.fetch("https://example.com/key", { + method: "PUT", + body: "value", + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.status).toBe(204); + response = await exports.default.fetch("https://example.com/key", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("value"); + + // Check key written under user's namespace + response = await env.DATABASE_SERVICE.fetch("https://placeholder/admin/key"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("value"); +}); diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/multiple-workers/test/tsconfig.json new file mode 100644 index 0000000..eea7b50 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../api-service/src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/tsconfig.json b/fixtures/vitest-pool-workers-examples/multiple-workers/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/multiple-workers/vitest.config.ts b/fixtures/vitest-pool-workers-examples/multiple-workers/vitest.config.ts new file mode 100644 index 0000000..4058552 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/multiple-workers/vitest.config.ts @@ -0,0 +1,123 @@ +import crypto from "node:crypto"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { importPKCS8, SignJWT } from "jose"; +import { Request, Response } from "miniflare"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +// Generate RSA keypair for signing/verifying JWTs +const authKeypair = crypto.generateKeyPairSync("rsa", { + modulusLength: 4096, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); +const authAlg = "RS256"; +const authPrivateKey = await importPKCS8(authKeypair.privateKey, authAlg); + +function isCredentialsObject( + value: unknown +): value is { username: string; password: string } { + return ( + typeof value === "object" && + value !== null && + "username" in value && + typeof value.username === "string" && + "password" in value && + typeof value.password === "string" + ); +} + +// Mapping between usernames and passwords +const passwords: Record = { + admin: "lovelace", +}; + +async function handleAuthServiceOutbound(request: Request): Promise { + const url = new URL(request.url); + + if (request.method === "POST" && url.pathname === "/login") { + // If this is a login request, verify the username/password, then sign a JWT + const body = await request.json(); + if (!isCredentialsObject(body)) { + return new Response("Bad Request", { status: 400 }); + } + if (passwords[body.username] !== body.password) { + return new Response("Unauthorized", { status: 401 }); + } + const payload = { "urn:example:username": body.username }; + const token = await new SignJWT(payload) + .setProtectedHeader({ alg: authAlg }) + .setIssuedAt() + .setIssuer("urn:example:issuer") + .setAudience("urn:example:audience") + .setExpirationTime("1h") + .sign(authPrivateKey); + return Response.json({ token }); + } + + return new Response("Not Found", { status: 404 }); +} + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + // Configuration for the test runner and "API service" Worker + wrangler: { + configPath: "./api-service/wrangler.jsonc", + }, + miniflare: { + bindings: { + TEST_AUTH_PUBLIC_KEY: authKeypair.publicKey, + }, + + workers: [ + // Configuration for "auxiliary" Worker dependencies. + // Unfortunately, auxiliary Workers cannot load their configuration + // from `wrangler.toml` files, and must be configured with Miniflare + // `WorkerOptions`. + { + name: "auth-service", + modules: true, + scriptPath: "./auth-service/dist/index.js", // Built by `global-setup.ts` + compatibilityDate: "2024-01-01", + compatibilityFlags: ["nodejs_compat"], + bindings: { AUTH_PUBLIC_KEY: authKeypair.publicKey }, + // Mock outbound `fetch()`es from the `auth-service` + outboundService: handleAuthServiceOutbound, + }, + { + name: "database-service", + modules: true, + scriptPath: "./database-service/dist/index.js", // Built by `global-setup.ts` + compatibilityDate: "2024-01-01", + compatibilityFlags: ["nodejs_compat"], + kvNamespaces: ["KV_NAMESPACE"], + }, + { + name: "tail-consumer", + modules: [ + { + path: "index.js", + type: "ESModule", + contents: /* javascript */ ` + export default { + tail(event) { + console.log("tail event received") + } + } + `, + }, + ], + compatibilityDate: "2024-01-01", + }, + ], + }, + }), + ], + test: { + globalSetup: ["./global-setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/package.json b/fixtures/vitest-pool-workers-examples/package.json new file mode 100644 index 0000000..b82c2e0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/package.json @@ -0,0 +1,62 @@ +{ + "name": "@fixture/vitest-pool-workers", + "private": true, + "type": "module", + "scripts": { + "cf-typegen": "pnpm run \"/^typegen:.*/\"", + "check:type": "node tsc-all.mjs", + "list": "vitest list", + "test": "vitest", + "test:ci": "vitest run", + "typegen:ai-vectorize": "wrangler types ./ai-vectorize/src/env.d.ts -c ./ai-vectorize/wrangler.jsonc --no-include-runtime", + "typegen:basics-unit-integration-self": "wrangler types ./basics-unit-integration-self/src/env.d.ts -c ./basics-unit-integration-self/wrangler.jsonc --no-include-runtime", + "typegen:container-app": "wrangler types ./container-app/src/env.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime", + "typegen:d1": "wrangler types ./d1/src/env.d.ts -c ./d1/wrangler.jsonc --no-include-runtime", + "typegen:durable-objects": "wrangler types ./durable-objects/src/env.d.ts -c ./durable-objects/wrangler.jsonc --no-include-runtime", + "typegen:durable-objects-exports": "wrangler types ./durable-objects-exports/src/env.d.ts -c ./durable-objects-exports/wrangler.jsonc --no-include-runtime", + "typegen:dynamic-import": "wrangler types ./dynamic-import/src/worker-configuration.d.ts -c ./dynamic-import/wrangler.jsonc --no-include-runtime", + "typegen:hyperdrive": "wrangler types ./hyperdrive/src/env.d.ts -c ./hyperdrive/wrangler.jsonc --no-include-runtime", + "typegen:images": "wrangler types ./images/src/env.d.ts -c ./images/wrangler.jsonc --no-include-runtime", + "typegen:kv-r2-caches": "wrangler types ./kv-r2-caches/src/env.d.ts -c ./kv-r2-caches/wrangler.jsonc --no-include-runtime", + "typegen:module-resolution": "wrangler types ./module-resolution/src/env.d.ts -c ./module-resolution/wrangler.jsonc --no-include-runtime", + "typegen:multiple-workers:api": "wrangler types ./multiple-workers/api-service/src/env.d.ts -c ./multiple-workers/api-service/wrangler.jsonc --no-include-runtime", + "typegen:multiple-workers:auth": "wrangler types ./multiple-workers/auth-service/src/env.d.ts -c ./multiple-workers/auth-service/wrangler.jsonc --no-include-runtime", + "typegen:multiple-workers:database": "wrangler types ./multiple-workers/database-service/src/env.d.ts -c ./multiple-workers/database-service/wrangler.jsonc --no-include-runtime", + "typegen:pipelines": "wrangler types ./pipelines/src/env.d.ts -c ./pipelines/wrangler.jsonc --no-include-runtime", + "typegen:queues": "wrangler types ./queues/src/env.d.ts -c ./queues/wrangler.jsonc --no-include-runtime", + "typegen:request-mocking": "wrangler types ./request-mocking/src/env.d.ts -c ./request-mocking/wrangler.jsonc --no-include-runtime", + "typegen:reset": "wrangler types ./reset/src/env.d.ts -c ./reset/wrangler.jsonc --no-include-runtime", + "typegen:rpc": "wrangler types ./rpc/src/env.d.ts -c ./rpc/wrangler.jsonc --no-include-runtime", + "typegen:web-assembly": "wrangler types ./web-assembly/src/env.d.ts -c ./web-assembly/wrangler.jsonc --no-include-runtime", + "typegen:workers-assets": "wrangler types ./workers-assets/src/env.d.ts -c ./workers-assets/wrangler.jsonc --no-include-runtime", + "typegen:workers-assets-no-dir": "wrangler types ./workers-assets-no-dir/src/env.d.ts -c ./workers-assets-no-dir/wrangler.jsonc --no-include-runtime", + "typegen:workflows": "wrangler types ./workflows/src/env.d.ts -c ./workflows/wrangler.jsonc --no-include-runtime" + }, + "devDependencies": { + "@better-auth/stripe": "^1.4.6", + "@cloudflare/containers": "^0.2.2", + "@cloudflare/vitest-pool-workers": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@microlabs/otel-cf-workers": "1.0.0-rc.45", + "@types/mime-types": "^3.0.1", + "@types/node": "catalog:default", + "@types/nunjucks": "^3.2.6", + "better-auth": "^1.4.6", + "cjs-wasm-module-dep": "file:./module-resolution/vendor/cjs-wasm-module-dep", + "discord-api-types": "0.37.98", + "esm-dep": "file:./module-resolution/vendor/esm-dep", + "ext-dep": "file:./module-resolution/vendor/ext-dep", + "jose": "^5.9.3", + "mime-types": "^2.1.35", + "miniflare": "workspace:*", + "msw": "catalog:default", + "nunjucks": "^3.2.4", + "stripe": "^20.0.0", + "toucan-js": "4.0.0", + "typescript": "catalog:default", + "vite": "catalog:default", + "vitest": "catalog:default", + "wasm-module-dep": "file:./module-resolution/vendor/wasm-module-dep", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore new file mode 100644 index 0000000..83e6278 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore @@ -0,0 +1 @@ +dist-functions diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md new file mode 100644 index 0000000..65945f2 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md @@ -0,0 +1,8 @@ +# ⚡️ pages-functions-unit-integration-self + +This project uses Pages Functions. Integration tests dispatch events using `exports.default` from the `cloudflare:workers` module. Unit tests call handler functions directly. [`global-setup.ts`](global-setup.ts) builds Pages Functions into a Worker for integration testing, watching for changes. + +| Test | Overview | +| --------------------------------------------------------- | ------------------------------------------------------------------------ | +| [integration-self.test.ts](test/integration-self.test.ts) | Basic `fetch` integration test using `exports.default` **(recommended)** | +| [unit.test.ts](test/unit.test.ts) | Basic unit test calling `worker.fetch()` directly | diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts new file mode 100644 index 0000000..2aaa5c5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts @@ -0,0 +1,11 @@ +// Add data to the request and make all bodies uppercase +export const onRequest: PagesFunction< + Env, + never, + Data | Record +> = async (ctx) => { + ctx.data = { user: "ada" }; + const response = await ctx.next(); + const text = await response.text(); + return new Response(text.toUpperCase(), response); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts new file mode 100644 index 0000000..67a0fc9 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts @@ -0,0 +1,11 @@ +export const onRequestGet: PagesFunction = async (ctx) => { + const key = `${ctx.data.user}:${ctx.params.key}`; + const value = await ctx.env.KV_NAMESPACE.get(key, "stream"); + return new Response(value, { status: value === null ? 204 : 200 }); +}; + +export const onRequestPut: PagesFunction = async (ctx) => { + const key = `${ctx.data.user}:${ctx.params.key}`; + await ctx.env.KV_NAMESPACE.put(key, ctx.request.body ?? ""); + return new Response(null, { status: 204 }); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts new file mode 100644 index 0000000..d8842ea --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts @@ -0,0 +1,3 @@ +export const onRequest: PagesFunction = ({ request }) => { + return new Response(`${request.method} pong`); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts new file mode 100644 index 0000000..055338d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts @@ -0,0 +1,6 @@ +interface Env { + KV_NAMESPACE: KVNamespace; + ASSETS: Fetcher; +} + +type Data = { user: string }; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts new file mode 100644 index 0000000..0ba0b7f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts @@ -0,0 +1,25 @@ +import childProcess from "node:child_process"; +import events from "node:events"; + +// Global setup runs inside Node.js, not `workerd` +export default async function () { + console.log( + "Building pages-functions-unit-integration-self and watching for changes..." + ); + + // Not building to `dist` here as Vitest ignores changes in `dist` by default + const buildProcess = childProcess.spawn( + "wrangler pages functions build --outdir dist-functions --watch", + { cwd: __dirname, shell: true } + ); + buildProcess.stdout.pipe(process.stdout); + buildProcess.stderr.pipe(process.stderr); + + // Wait for first build + await events.once(buildProcess.stdout, "data"); + + // Stop watching for changes on teardown + return () => { + buildProcess.kill(); + }; +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html new file mode 100644 index 0000000..5854f2f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html @@ -0,0 +1 @@ +

Not found 😭

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers new file mode 100644 index 0000000..0b19dba --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers @@ -0,0 +1,4 @@ +/secure + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects new file mode 100644 index 0000000..54dab31 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects @@ -0,0 +1 @@ +/take-me-home / 302 diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html new file mode 100644 index 0000000..640142f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html @@ -0,0 +1 @@ +

Homepage 🏡

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html new file mode 100644 index 0000000..acbd884 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html @@ -0,0 +1 @@ +

Secure page 🔐

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts new file mode 100644 index 0000000..49c7bb1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts @@ -0,0 +1,10 @@ +declare namespace Cloudflare { + interface GlobalProps { + // Pages Functions compile to a worker with an ExportedHandler default export + mainModule: { default: ExportedHandler }; + } + interface Env { + KV_NAMESPACE: KVNamespace; + ASSETS: Fetcher; + } +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts new file mode 100644 index 0000000..d6ec247 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts @@ -0,0 +1,71 @@ +import { exports } from "cloudflare:workers"; +import { describe, it } from "vitest"; + +describe("functions", () => { + it("calls function", async ({ expect }) => { + // `exports.default` here points to the worker running in the current isolate. + // This gets its handler from the `main` option in `vitest.config.mts`. + const response = await exports.default.fetch("http://example.com/api/ping"); + // All `/api/*` requests go through `functions/api/_middleware.ts`, + // which makes all response bodies uppercase + expect(await response.text()).toBe("GET PONG"); + }); + + it("calls function with params", async ({ expect }) => { + let response = await exports.default.fetch( + "https://example.com/api/kv/key", + { + method: "PUT", + body: "value", + } + ); + expect(response.status).toBe(204); + + response = await exports.default.fetch("https://example.com/api/kv/key"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("VALUE"); + }); +}); + +describe("assets", () => { + it("serves static assets", async ({ expect }) => { + const response = await exports.default.fetch("http://example.com/"); + expect(await response.text()).toMatchInlineSnapshot(` + "

Homepage 🏡

+ " + `); + }); + + it("respects 404.html", async ({ expect }) => { + // `404.html` should be served for all unmatched requests + const response = await exports.default.fetch( + "http://example.com/not-found" + ); + expect(await response.text()).toMatchInlineSnapshot(` + "

Not found 😭

+ " +`); + }); + + it("respects _redirects", async ({ expect }) => { + const response = await exports.default.fetch( + "http://example.com/take-me-home", + { + redirect: "manual", + } + ); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("respects _headers", async ({ expect }) => { + let response = await exports.default.fetch("http://example.com/secure"); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("Referrer-Policy")).toBe("no-referrer"); + + // Check headers only added to matching requests + response = await exports.default.fetch("http://example.com/"); + expect(response.headers.get("X-Frame-Options")).toBe(null); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json new file mode 100644 index 0000000..3da5187 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../functions/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts new file mode 100644 index 0000000..e70873f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts @@ -0,0 +1,66 @@ +import { + createPagesEventContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { describe, it } from "vitest"; +import * as apiMiddleware from "../functions/api/_middleware"; +import * as apiKVKeyFunction from "../functions/api/kv/[key]"; +import * as apiPingFunction from "../functions/api/ping"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `createPagesEventContext()`. +const IncomingRequest = Request; + +describe("functions", () => { + it("calls function", async ({ expect }) => { + const request = new IncomingRequest("http://example.com/api/ping"); + const ctx = createPagesEventContext({ + request, + data: { user: "test" }, + }); + const response = await apiPingFunction.onRequest(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe("GET pong"); + }); + + it("calls function with params", async ({ expect }) => { + let request = new IncomingRequest("http://example.com/api/kv/key", { + method: "PUT", + body: "value", + }); + let ctx = createPagesEventContext({ + request, + data: { user: "test" }, + params: { key: "key" }, + }); + let response = await apiKVKeyFunction.onRequestPut(ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(204); + + request = new IncomingRequest("http://example.com/api/kv/key"); + ctx = createPagesEventContext({ + request, + data: { user: "test" }, + params: { key: "key" }, + }); + response = await apiKVKeyFunction.onRequestGet(ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(200); + expect(await response.text()).toBe("value"); + }); + + it("calls middleware", async ({ expect }) => { + const request = new IncomingRequest("http://example.com/api/ping"); + const ctx = createPagesEventContext({ + request, + async next(request) { + expect(ctx.data).toStrictEqual({ user: "ada" }); + return new Response(`next:${request.method} ${request.url}`); + }, + }); + const response = await apiMiddleware.onRequest(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe("NEXT:GET HTTP://EXAMPLE.COM/API/PING"); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts new file mode 100644 index 0000000..89818ce --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { + buildPagesASSETSBinding, + cloudflareTest, +} from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +const assetsPath = path.join(__dirname, "public"); + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + main: "./dist-functions/index.js", // Built by `global-setup.ts` + miniflare: { + compatibilityFlags: ["nodejs_compat"], + compatibilityDate: "2024-01-01", + kvNamespaces: ["KV_NAMESPACE"], + serviceBindings: { + ASSETS: await buildPagesASSETSBinding(assetsPath), + }, + }, + }), + ], + test: { + // Only required for integration tests + globalSetup: ["./global-setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/pages-with-config/README.md b/fixtures/vitest-pool-workers-examples/pages-with-config/README.md new file mode 100644 index 0000000..4ca02e8 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-with-config/README.md @@ -0,0 +1,3 @@ +# pages-with-config + +Tests for regression of [#5768](https://github.com/cloudflare/workers-sdk/issues/5768) diff --git a/fixtures/vitest-pool-workers-examples/pages-with-config/pages-config.test.ts b/fixtures/vitest-pool-workers-examples/pages-with-config/pages-config.test.ts new file mode 100644 index 0000000..440de74 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-with-config/pages-config.test.ts @@ -0,0 +1,7 @@ +import { it } from "vitest"; + +it("should run tests even if Pages project specifies wrangler config file", ({ + expect, +}) => { + expect(1).toBe(1); +}); diff --git a/fixtures/vitest-pool-workers-examples/pages-with-config/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-with-config/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-with-config/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-with-config/vitest.config.ts b/fixtures/vitest-pool-workers-examples/pages-with-config/vitest.config.ts new file mode 100644 index 0000000..d9a889a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-with-config/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/pages-with-config/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/pages-with-config/wrangler.jsonc new file mode 100644 index 0000000..5916924 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-with-config/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "pages-with-config", + // don't provide compatibility_date so that vitest will infer the latest one + "pages_build_output_dir": "public", +} diff --git a/fixtures/vitest-pool-workers-examples/pipelines/README.md b/fixtures/vitest-pool-workers-examples/pipelines/README.md new file mode 100644 index 0000000..2bdc62f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/README.md @@ -0,0 +1,8 @@ +# 🚰 pipelines + +This Worker implements endpoint that send details of the incoming HTTP request to a Pipeline. + +| Test | Overview | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [pipeline-send-integration-self.test.ts](test/pipeline-send-integration-self.test.ts) | Integration tests for endpoints using `exports` | +| [pipeline-send-unit.test.ts](test/pipeline-send-unit.test.ts) | Unit tests calling `worker.fetch()` directly mocking the Pipeline | diff --git a/fixtures/vitest-pool-workers-examples/pipelines/src/env.d.ts b/fixtures/vitest-pool-workers-examples/pipelines/src/env.d.ts new file mode 100644 index 0000000..e0059f8 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/src/env.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./pipelines/src/env.d.ts -c ./pipelines/wrangler.jsonc --no-include-runtime` (hash: 75410e3d85500fe5c403446edfdba23c) +interface __BaseEnv_Env { + PIPELINE: import("cloudflare:pipelines").Pipeline< + import("cloudflare:pipelines").PipelineRecord + >; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/pipelines/src/index.ts b/fixtures/vitest-pool-workers-examples/pipelines/src/index.ts new file mode 100644 index 0000000..1d1fc05 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/src/index.ts @@ -0,0 +1,11 @@ +export default { + async fetch(request, env, ctx) { + await env.PIPELINE.send([ + { + method: request.method.toUpperCase(), + url: request.url, + }, + ]); + return new Response("Accepted", { status: 202 }); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/pipelines/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/pipelines/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-integration-self.test.ts b/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-integration-self.test.ts new file mode 100644 index 0000000..6d9a48b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-integration-self.test.ts @@ -0,0 +1,12 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("sends message to pipeline", async ({ expect }) => { + // Send data to the Pipeline + const response = await exports.default.fetch("https://example.com/ingest", { + method: "POST", + body: "value", + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe("Accepted"); +}); diff --git a/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-unit.test.ts b/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-unit.test.ts new file mode 100644 index 0000000..702071d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/test/pipeline-send-unit.test.ts @@ -0,0 +1,45 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, it, vi } from "vitest"; +import worker from "../src/index"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `worker.fetch()`. +const IncomingRequest = Request; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("produces message to pipeline", async ({ expect }) => { + const mockPipeline = { + send: vi.fn().mockResolvedValue(undefined), + }; + + const testEnv = { + ...env, + PIPELINE: mockPipeline, + }; + + // Send data to pipeline + const request = new IncomingRequest("https://example.com/ingest", { + method: "POST", + body: "value", + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, testEnv, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(202); + expect(await response.text()).toBe("Accepted"); + + // Check `PIPELINE.send()` was called + expect(mockPipeline.send).toHaveBeenCalledTimes(1); + expect(mockPipeline.send).toHaveBeenCalledWith([ + { method: "POST", url: "https://example.com/ingest" }, + ]); +}); diff --git a/fixtures/vitest-pool-workers-examples/pipelines/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/pipelines/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pipelines/tsconfig.json b/fixtures/vitest-pool-workers-examples/pipelines/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pipelines/vitest.config.ts b/fixtures/vitest-pool-workers-examples/pipelines/vitest.config.ts new file mode 100644 index 0000000..4217062 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/pipelines/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/pipelines/wrangler.jsonc new file mode 100644 index 0000000..8eccd5a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pipelines/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "name": "pipelines", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "pipelines": [ + { + "binding": "PIPELINE", + "stream": "my-pipeline", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/queues/README.md b/fixtures/vitest-pool-workers-examples/queues/README.md new file mode 100644 index 0000000..7551af7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/README.md @@ -0,0 +1,10 @@ +# 🚥 queues + +This Worker implements a `PUT` endpoint that queues jobs on a queue, storing results in a KV namespace, and a `GET` endpoint that retrieves results from KV. Each job converts the request body to uppercase. + +| Test | Overview | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [queue-consumer-integration-self.test.ts](test/queue-consumer-integration-self.test.ts) | `queue` handler integration test using `exports` | +| [queue-consumer-unit.test.ts](test/queue-consumer-unit.test.ts) | Unit tests calling `worker.queue()` directly | +| [queue-producer-integration-self.test.ts](test/queue-producer-integration-self.test.ts) | Integration tests for endpoints using `exports` | +| [queue-producer-unit.test.ts](test/queue-producer-unit.test.ts) | Unit tests calling `worker.fetch()` directly mocking enqueuing and the consumer | diff --git a/fixtures/vitest-pool-workers-examples/queues/src/env.d.ts b/fixtures/vitest-pool-workers-examples/queues/src/env.d.ts new file mode 100644 index 0000000..d1ef862 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/src/env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./queues/src/env.d.ts -c ./queues/wrangler.jsonc --no-include-runtime` (hash: d686d455d34c5b143559729c82938b10) +interface __BaseEnv_Env { + QUEUE_RESULTS: KVNamespace; + QUEUE_PRODUCER: Queue; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/queues/src/index.ts b/fixtures/vitest-pool-workers-examples/queues/src/index.ts new file mode 100644 index 0000000..bbf413b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/src/index.ts @@ -0,0 +1,34 @@ +export interface QueueJob { + key: string; + value: string; +} + +async function processJob(env: Env, job: QueueJob) { + const result = job.value.toUpperCase(); + await env.QUEUE_RESULTS.put(job.key, result); +} + +export default { + async fetch(request, env, ctx) { + const { pathname } = new URL(request.url); + if (request.method === "GET") { + const value = await env.QUEUE_RESULTS.get(pathname, "stream"); + return new Response(value, { status: value === null ? 404 : 200 }); + } else if (request.method === "POST") { + const value = await request.text(); + await env.QUEUE_PRODUCER.send({ key: pathname, value }); + return new Response("Accepted", { status: 202 }); + } else { + return new Response("Method Not Allowed", { status: 405 }); + } + }, + async queue(batch, env, ctx) { + for (const message of batch.messages) { + await processJob(env, message.body); + message.ack(); + } + }, +} satisfies ExportedHandler; +// ^ Using `satisfies` provides type checking/completions for `ExportedHandler` +// whilst still allowing us to call `worker.fetch()` and `worker.queue()` in +// tests without asserting they're defined. diff --git a/fixtures/vitest-pool-workers-examples/queues/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/queues/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-integration-self.test.ts b/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-integration-self.test.ts new file mode 100644 index 0000000..f311567 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-integration-self.test.ts @@ -0,0 +1,36 @@ +import { randomBytes } from "node:crypto"; +import { env, exports } from "cloudflare:workers"; +import { it } from "vitest"; +import type { QueueJob } from "../src/index"; + +it("consumes queue messages", async ({ expect }) => { + // `exports.default` here points to the worker running in the current isolate. + // This gets its handler from the `main` option in `vitest.config.mts`. + // Importantly, it uses the exact `import("../src").default` instance we could + // import in this file as its handler. Note the `exports.default.queue()` method + // is experimental, and requires the `service_binding_extra_handlers` + // compatibility flag to be enabled. + const messages: ServiceBindingQueueMessage[] = [ + { + id: randomBytes(16).toString("hex"), + timestamp: new Date(1000), + attempts: 1, + body: { key: "/1", value: "one" }, + }, + { + id: randomBytes(16).toString("hex"), + timestamp: new Date(2000), + attempts: 1, + body: { key: "/2", value: "two" }, + }, + ]; + const result = await exports.default.queue("queue", messages); + expect(result.outcome).toBe("ok"); + expect(result.retryBatch.retry).toBe(false); // `true` if `batch.retryAll()` called + expect(result.ackAll).toBe(false); // `true` if `batch.ackAll()` called + expect(result.retryMessages).toStrictEqual([]); + expect(result.explicitAcks).toStrictEqual([messages[0].id, messages[1].id]); + + expect(await env.QUEUE_RESULTS.get("/1")).toBe("ONE"); + expect(await env.QUEUE_RESULTS.get("/2")).toBe("TWO"); +}); diff --git a/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-unit.test.ts b/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-unit.test.ts new file mode 100644 index 0000000..fd1e5dd --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/test/queue-consumer-unit.test.ts @@ -0,0 +1,42 @@ +import { randomBytes } from "node:crypto"; +import { + createExecutionContext, + createMessageBatch, + getQueueResult, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; +import worker from "../src/index"; +import type { QueueJob } from "../src/index"; + +it("consumes queue messages", async ({ expect }) => { + // Call `queue()` handler directly + const messages: ServiceBindingQueueMessage[] = [ + { + id: randomBytes(16).toString("hex"), + timestamp: new Date(1000), + attempts: 1, + body: { key: "/1", value: "one" }, + }, + { + id: randomBytes(16).toString("hex"), + timestamp: new Date(2000), + attempts: 1, + body: { key: "/2", value: "two" }, + }, + ]; + const batch = createMessageBatch("queue", messages); + const ctx = createExecutionContext(); + await worker.queue(batch, env, ctx); + + // `getQueueResult()` implicitly calls `waitOnExecutionContext()` + const result = await getQueueResult(batch, ctx); + expect(result.outcome).toBe("ok"); + expect(result.retryBatch.retry).toBe(false); // `true` if `batch.retryAll()` called + expect(result.ackAll).toBe(false); // `true` if `batch.ackAll()` called + expect(result.retryMessages).toStrictEqual([]); + expect(result.explicitAcks).toStrictEqual([messages[0].id, messages[1].id]); + + expect(await env.QUEUE_RESULTS.get("/1")).toBe("ONE"); + expect(await env.QUEUE_RESULTS.get("/2")).toBe("TWO"); +}); diff --git a/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-integration-self.test.ts b/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-integration-self.test.ts new file mode 100644 index 0000000..95371e3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-integration-self.test.ts @@ -0,0 +1,20 @@ +import { exports } from "cloudflare:workers"; +import { it, vi } from "vitest"; + +it("produces and consumers queue message", async ({ expect }) => { + // Enqueue job on queue + let response = await exports.default.fetch("https://example.com/key", { + method: "POST", + body: "value", + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe("Accepted"); + + // Wait for job to be processed + const result = await vi.waitUntil(async () => { + const response = await exports.default.fetch("https://example.com/key"); + const text = await response.text(); + if (response.ok) return text; + }); + expect(result).toBe("VALUE"); +}); diff --git a/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-unit.test.ts b/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-unit.test.ts new file mode 100644 index 0000000..59244e3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/test/queue-producer-unit.test.ts @@ -0,0 +1,79 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, assert, it, vi } from "vitest"; +import worker from "../src/index"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `worker.fetch()`. +const IncomingRequest = Request; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("produces queue message with mocked send", async ({ expect }) => { + // Intercept calls to `QUEUE_PRODUCER.send()` + const sendSpy = vi + .spyOn(env.QUEUE_PRODUCER, "send") + .mockImplementation(async () => ({ + metadata: { + metrics: { + backlogCount: 0, + backlogBytes: 0, + oldestMessageTimestamp: new Date(0), + }, + }, + })); + + // Enqueue job on queue + const request = new IncomingRequest("https://example.com/key", { + method: "POST", + body: "value", + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(202); + expect(await response.text()).toBe("Accepted"); + + // Check `QUEUE_PRODUCER.send()` was called + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith({ key: "/key", value: "value" }); +}); + +it("produces queue message with mocked consumer", async ({ expect }) => { + // Intercept calls to `worker.queue()`. Note the runner worker has a queue + // consumer configured that gets its handler from the `main` option in + // `vitest.config.mts`. Importantly, this uses the exact `worker` instance + // we're spying on here. + const consumerSpy = vi + .spyOn(worker, "queue") + .mockImplementation(async () => {}); + + // Enqueue job on queue + const request = new IncomingRequest("https://example.com/key", { + method: "POST", + body: "another value", + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(202); + expect(await response.text()).toBe("Accepted"); + + // Wait for consumer to be called + await vi.waitUntil(() => consumerSpy.mock.calls.length > 0); + expect(consumerSpy).toHaveBeenCalledTimes(1); + const batch = consumerSpy.mock.lastCall?.[0]; + assert(batch); + expect(batch.messages[0].body).toStrictEqual({ + key: "/key", + value: "another value", + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/queues/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/queues/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/queues/tsconfig.json b/fixtures/vitest-pool-workers-examples/queues/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/queues/vitest.config.ts b/fixtures/vitest-pool-workers-examples/queues/vitest.config.ts new file mode 100644 index 0000000..fe3a8af --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/vitest.config.ts @@ -0,0 +1,25 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + // Required to use `exports.default.queue()`. This is an experimental + // compatibility flag, and cannot be enabled in production. + compatibilityFlags: ["service_binding_extra_handlers"], + // Use a shorter `max_batch_timeout` in tests + queueConsumers: { + queue: { maxBatchTimeout: 0.05 /* 50ms */ }, + }, + }, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/queues/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/queues/wrangler.jsonc new file mode 100644 index 0000000..373766a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/queues/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "name": "queues", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "kv_namespaces": [ + { + "binding": "QUEUE_RESULTS", + "id": "00000000000000000000000000000000", + }, + ], + "queues": { + "producers": [ + { + "binding": "QUEUE_PRODUCER", + "queue": "queue", + }, + ], + "consumers": [ + { + "queue": "queue", + "max_batch_timeout": 3, // Overridden in `vitest.config.mts` + }, + ], + }, +} diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/README.md b/fixtures/vitest-pool-workers-examples/request-mocking/README.md new file mode 100644 index 0000000..d52ba33 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/README.md @@ -0,0 +1,8 @@ +# 🤹 request-mocking + +This Worker rewrites the host of all incoming requests to `cloudflare.com` then forwards the request on. Tests demonstrate declarative mocking with [MSW (Mock Service Worker)](https://mswjs.io/), and imperative mocks of `globalThis.fetch()`. Note mocking WebSocket requests is only supported with imperative mocking. + +| Test | Overview | +| ----------------------------------------------- | ----------------------------------------------------------------------- | +| [declarative.test.ts](test/declarative.test.ts) | Integration tests with declarative request mocking using MSW | +| [imperative.test.ts](test/imperative.test.ts) | Integration tests with imperative request mocking, including WebSockets | diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/src/env.d.ts b/fixtures/vitest-pool-workers-examples/request-mocking/src/env.d.ts new file mode 100644 index 0000000..f2c3b51 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/src/env.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./request-mocking/src/env.d.ts -c ./request-mocking/wrangler.jsonc --no-include-runtime` (hash: 0c8cd5ae12176ebab8337cbfef98b516) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/src/index.ts b/fixtures/vitest-pool-workers-examples/request-mocking/src/index.ts new file mode 100644 index 0000000..b6a4b83 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/src/index.ts @@ -0,0 +1,11 @@ +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + url.host = "cloudflare.com"; + try { + return await fetch(url, request); + } catch (e) { + return new Response(String(e), { status: 500 }); + } + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/request-mocking/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/test/declarative.test.ts b/fixtures/vitest-pool-workers-examples/request-mocking/test/declarative.test.ts new file mode 100644 index 0000000..e785184 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/test/declarative.test.ts @@ -0,0 +1,65 @@ +import { exports } from "cloudflare:workers"; +import { http, HttpResponse } from "msw"; +import { expect, it } from "vitest"; +import { server } from "./server"; + +it("mocks GET requests", async () => { + server.use( + http.get( + "https://cloudflare.com/once", + () => { + return HttpResponse.text("😉"); + }, + { once: true } + ), + http.get("https://cloudflare.com/persistent", () => { + return HttpResponse.text("📌"); + }) + ); + + // Host `example.com` will be rewritten to `cloudflare.com` by the Worker + let response = await exports.default.fetch("https://example.com/once"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("😉"); + + // Subsequent `fetch()`es fail... + response = await exports.default.fetch("https://example.com/once"); + expect(response.status).toBe(500); + expect(await response.text()).toMatch("Cannot bypass"); + + // ...but calling `.persist()` will match forever, with `.times(n)` matching + // `n` times + for (let i = 0; i < 3; i++) { + response = await exports.default.fetch("https://example.com/persistent"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("📌"); + } +}); + +it("mocks POST requests", async () => { + server.use( + http.post("https://cloudflare.com/path", async ({ request }) => { + const text = await request.text(); + if (text !== "✨") { + return HttpResponse.text("Bad request body", { status: 400 }); + } + return HttpResponse.text("✅"); + }) + ); + + // Sending a request without the expected body returns an error response... + let response = await exports.default.fetch("https://example.com/path", { + method: "POST", + body: "🙃", + }); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Bad request body"); + + // ...but the correct body should succeed + response = await exports.default.fetch("https://example.com/path", { + method: "POST", + body: "✨", + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("✅"); +}); diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts b/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts new file mode 100644 index 0000000..e19c13c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts @@ -0,0 +1,104 @@ +import events from "node:events"; +import { exports } from "cloudflare:workers"; +import { afterEach, assert, it, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("mocks GET requests", async ({ expect }) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + + if ( + request.method === "GET" && + url.origin === "https://cloudflare.com" && + url.pathname === "/path" + ) { + return new Response("✅"); + } + + throw new Error("No mock found"); + }); + + // Host `example.com` will be rewritten to `cloudflare.com` by the Worker + let response = await exports.default.fetch("https://example.com/path"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("✅"); + + // Invalid paths shouldn't match + response = await exports.default.fetch("https://example.com/bad"); + expect(response.status).toBe(500); + expect(await response.text()).toMatch("No mock found"); +}); + +it("mocks POST requests", async ({ expect }) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const body = await request.text(); + + if ( + request.method === "POST" && + url.origin === "https://cloudflare.com" && + url.pathname === "/path" && + body === "✨" + ) { + return new Response("✅"); + } + + throw new Error("No mock found"); + }); + + const response = await exports.default.fetch("https://example.com/path", { + method: "POST", + body: "✨", + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("✅"); +}); + +it("mocks WebSocket requests", async ({ expect }) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + + if ( + request.method === "GET" && + url.origin === "https://cloudflare.com" && + url.pathname === "/ws" && + request.headers.get("Upgrade") === "websocket" + ) { + const { 0: socket, 1: responseSocket } = new WebSocketPair(); + socket.addEventListener("message", (event) => { + assert(typeof event.data === "string"); + socket.send(event.data.toUpperCase()); + }); + socket.accept(); + return new Response(null, { + status: 101, + webSocket: responseSocket, + }); + } + + throw new Error("No mock found"); + }); + + // Send WebSocket request and assert WebSocket response received... + const response = await exports.default.fetch("https://example.com/ws", { + headers: { Upgrade: "websocket" }, + }); + expect(response.status).toBe(101); + const webSocket = response.webSocket; + assert(webSocket !== null); // Using `assert()` for type narrowing + + // ...then accept WebSocket and send/receive message + const eventPromise = events.once(webSocket, "message") as Promise< + [MessageEvent] /* args */ + >; + webSocket.accept(); + webSocket.send("hello"); + const args = await eventPromise; + expect(args[0].data).toBe("HELLO"); +}); diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/test/server.ts b/fixtures/vitest-pool-workers-examples/request-mocking/test/server.ts new file mode 100644 index 0000000..bd0bda5 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/test/server.ts @@ -0,0 +1,3 @@ +import { setupServer } from "msw/node"; + +export const server = setupServer(); diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/test/setup.ts b/fixtures/vitest-pool-workers-examples/request-mocking/test/setup.ts new file mode 100644 index 0000000..891a479 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/test/setup.ts @@ -0,0 +1,10 @@ +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./server"; + +beforeAll(() => + server.listen({ + onUnhandledRequest: "error", + }) +); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/request-mocking/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/tsconfig.json b/fixtures/vitest-pool-workers-examples/request-mocking/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/vitest.config.ts b/fixtures/vitest-pool-workers-examples/request-mocking/vitest.config.ts new file mode 100644 index 0000000..20f7975 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/vitest.config.ts @@ -0,0 +1,19 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + test: { + setupFiles: ["test/setup.ts"], + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/request-mocking/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/request-mocking/wrangler.jsonc new file mode 100644 index 0000000..17f88cb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/request-mocking/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "request-mocking", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one +} diff --git a/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts b/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts new file mode 100644 index 0000000..d9ccb38 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/src/env.d.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --config=./reset/wrangler.jsonc --include-runtime=false ./reset/src/env.d.ts` (hash: 33577604668b74d20af09cc4348f7c27) +interface __BaseEnv_Env { + KV_NAMESPACE: KVNamespace; + R2_BUCKET: R2Bucket; + DATABASE: D1Database; + RATE_LIMITER: RateLimit; + RATE_LIMITER_ALIAS: RateLimit; + COUNTER: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "Counter"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/reset/src/index.ts b/fixtures/vitest-pool-workers-examples/reset/src/index.ts new file mode 100644 index 0000000..5c719f8 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/src/index.ts @@ -0,0 +1,27 @@ +import { DurableObject } from "cloudflare:workers"; + +export class Counter extends DurableObject { + count: number = 0; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + void ctx.blockConcurrencyWhile(async () => { + this.count = (await ctx.storage.get("count")) ?? 0; + }); + } + + fetch() { + this.count++; + void this.ctx.storage.put("count", this.count); + return new Response(this.count.toString()); + } +} + +export default >{ + fetch(request, env) { + const { pathname } = new URL(request.url); + const id = env.COUNTER.idFromName(pathname); + const stub = env.COUNTER.get(id); + return stub.fetch(request); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/reset/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/reset/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts b/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts new file mode 100644 index 0000000..e995eaa --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts @@ -0,0 +1,105 @@ +import { reset } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, it } from "vitest"; + +afterEach(async () => { + await reset(); +}); + +it("clears KV storage between tests", async ({ expect }) => { + expect(await env.KV_NAMESPACE.get("key")).toBe(null); + await env.KV_NAMESPACE.put("key", "value"); + expect(await env.KV_NAMESPACE.get("key")).toBe("value"); +}); + +it("sees empty KV storage after reset", async ({ expect }) => { + expect(await env.KV_NAMESPACE.get("key")).toBe(null); +}); + +it("clears R2 storage between tests", async ({ expect }) => { + const listed = await env.R2_BUCKET.list(); + expect(listed.objects.length).toBe(0); + await env.R2_BUCKET.put("object", new Uint8Array([1, 2, 3])); + const listedAfter = await env.R2_BUCKET.list(); + expect(listedAfter.objects.length).toBe(1); +}); + +it("sees empty R2 storage after reset", async ({ expect }) => { + const listed = await env.R2_BUCKET.list(); + expect(listed.objects.length).toBe(0); +}); + +it("clears D1 storage between tests", async ({ expect }) => { + await env.DATABASE.prepare( + "CREATE TABLE IF NOT EXISTS test (key TEXT PRIMARY KEY, value TEXT)" + ).run(); + await env.DATABASE.prepare( + "INSERT INTO test (key, value) VALUES ('key', 'value')" + ).run(); + const result = await env.DATABASE.prepare( + "SELECT value FROM test WHERE key = 'key'" + ).first(); + expect(result?.value).toBe("value"); +}); + +it("sees empty D1 storage after reset", async ({ expect }) => { + const result = await env.DATABASE.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='test'" + ).first(); + expect(result).toBe(null); +}); + +it("clears Durable Object storage between tests", async ({ expect }) => { + const id = env.COUNTER.idFromName("/test"); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); +}); + +it("sees reset Durable Object storage after reset", async ({ expect }) => { + const id = env.COUNTER.idFromName("/test"); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); +}); + +it("exhausts ratelimit then resets between tests", async ({ expect }) => { + // First call succeeds + const first = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(first.success).toBe(true); + + // Exhaust the full limit of 100 + for (let i = 1; i < 100; i++) { + await env.RATE_LIMITER.limit({ key: "test-key" }); + } + + // 101st call should fail + const over = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(over.success).toBe(false); +}); + +it("sees reset ratelimit state after reset", async ({ expect }) => { + // After reset(), buckets should be cleared — first call succeeds again + const result = await env.RATE_LIMITER.limit({ key: "test-key" }); + expect(result.success).toBe(true); +}); + +it("shares ratelimit state across bindings with the same namespace_id", async ({ + expect, +}) => { + // RATE_LIMITER and RATE_LIMITER_ALIAS both reference namespace_id "1", so + // they increment the same underlying counter for a given key. + for (let i = 0; i < 100; i++) { + const res = await env.RATE_LIMITER.limit({ key: "shared" }); + expect(res.success).toBe(true); + } + + // The 101st call — made through the *other* binding — is rejected because + // the counter is shared across both bindings. + const viaAlias = await env.RATE_LIMITER_ALIAS.limit({ key: "shared" }); + expect(viaAlias.success).toBe(false); + + // A different key is still tracked independently within the namespace. + const otherKey = await env.RATE_LIMITER_ALIAS.limit({ key: "fresh" }); + expect(otherKey.success).toBe(true); +}); diff --git a/fixtures/vitest-pool-workers-examples/reset/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/reset/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/reset/tsconfig.json b/fixtures/vitest-pool-workers-examples/reset/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/reset/vitest.config.ts b/fixtures/vitest-pool-workers-examples/reset/vitest.config.ts new file mode 100644 index 0000000..2590f0b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/vitest.config.ts @@ -0,0 +1,19 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + test: { + name: "@scoped/reset", + }, + }) +); diff --git a/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc new file mode 100644 index 0000000..90b3721 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc @@ -0,0 +1,51 @@ +{ + "name": "reset", + "main": "src/index.ts", + "durable_objects": { + "bindings": [ + { + "name": "COUNTER", + "class_name": "Counter", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["Counter"], + }, + ], + "kv_namespaces": [ + { + "binding": "KV_NAMESPACE", + "id": "00000000000000000000000000000000", + }, + ], + "r2_buckets": [ + { + "binding": "R2_BUCKET", + "bucket_name": "bucket", + }, + ], + "d1_databases": [ + { + "binding": "DATABASE", + "database_name": "database", + "database_id": "00000000-0000-0000-0000-000000000000", + }, + ], + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "1", + "simple": { "limit": 100, "period": 60 }, + }, + { + // Deliberately shares namespace_id "1" with RATE_LIMITER: two bindings + // pointing at the same namespace must share a single counter. + "name": "RATE_LIMITER_ALIAS", + "namespace_id": "1", + "simple": { "limit": 100, "period": 60 }, + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/README.md b/fixtures/vitest-pool-workers-examples/rpc/README.md new file mode 100644 index 0000000..59ecefe --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/README.md @@ -0,0 +1,8 @@ +# 🤯 rpc + +This Worker defines `WorkerEntrypoint` default and named exports. It also defines Durable Objects subclassing `DurableObject`. All of these classes define properties and methods that can be called over RPC. Integration tests dispatch events, access properties and call methods via `exports` imported from `cloudflare:workers`. + +| Test | Overview | +| --------------------------------------------------------- | -------------------------------------------- | +| [integration-self.test.ts](test/integration-self.test.ts) | Integration test using `exports` | +| [unit.test.ts](test/unit.test.ts) | Unit tests calling exported classes directly | diff --git a/fixtures/vitest-pool-workers-examples/rpc/src/counter.ts b/fixtures/vitest-pool-workers-examples/rpc/src/counter.ts new file mode 100644 index 0000000..645cf54 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/src/counter.ts @@ -0,0 +1,26 @@ +import { RpcTarget } from "cloudflare:workers"; + +export class Counter extends RpcTarget { + #value: number; + + constructor(value: number) { + super(); + this.#value = value; + } + + get value() { + return this.#value; + } + + increment(by = 1) { + return (this.#value += by); + } + + clone() { + return new Counter(this.#value); + } + + asObject() { + return { val: this.#value }; + } +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/src/env.d.ts b/fixtures/vitest-pool-workers-examples/rpc/src/env.d.ts new file mode 100644 index 0000000..2534a64 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/src/env.d.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./rpc/src/env.d.ts -c ./rpc/wrangler.jsonc --no-include-runtime` (hash: 86dc5f25d2f0268adc69f5c054dadba6) +interface __BaseEnv_Env { + KV_NAMESPACE: KVNamespace; + TEST_OBJECT: DurableObjectNamespace; + PROXIED_TEST_OBJECT: DurableObjectNamespace< + import("./index").ProxiedTestObject + >; + TEST_NAMED_HANDLER: Service; + TEST_NAMED_ENTRYPOINT: Service; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + durableNamespaces: "TestObject" | "ProxiedTestObject"; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/rpc/src/index.ts b/fixtures/vitest-pool-workers-examples/rpc/src/index.ts new file mode 100644 index 0000000..92f4252 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/src/index.ts @@ -0,0 +1,188 @@ +import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import { Counter } from "./counter"; + +export class TestObject extends DurableObject { + #value: number = 0; + #log: string[] = []; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.overriddenPrototypeMethod = () => "instance"; + void ctx.blockConcurrencyWhile(async () => { + this.#value = (await ctx.storage.get("count")) ?? 0; + }); + } + + get value() { + return this.#value; + } + + increment(by = 1) { + this.#value += by; + void this.ctx.storage.put("count", this.#value); + return this.#value; + } + + scheduleReset(afterMillis: number) { + void this.ctx.storage.setAlarm(Date.now() + afterMillis); + } + + record(value: string) { + this.#log.push(value); + } + + getLog() { + return this.#log; + } + + async recordFromDurableObject(targetName: string, calls: number) { + const id = this.env.TEST_OBJECT.idFromName(targetName); + const stub = this.env.TEST_OBJECT.get(id); + const promises: Promise[] = []; + + for (let i = 0; i < calls; i++) { + promises.push(stub.record(`call-${i}`)); + } + + await Promise.all(promises); + return stub.getLog(); + } + + async fetch(request: Request) { + return Response.json({ + source: "TestObject", + method: request.method, + url: request.url, + ctxWaitUntil: typeof this.ctx.waitUntil, + envKeys: Object.keys(this.env).sort(), + }); + } + + overriddenPrototypeMethod() { + return "prototype"; + } + + getCounter() { + return new Counter(0); + } + + getObject() { + return { hello: "world" }; + } + + alarm() { + this.#value = 0; + void this.ctx.storage.put("count", this.#value); + } + + instanceProperty = "👻"; +} + +export class ProxiedTestObject extends DurableObject { + #value = "private value"; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + return new Proxy(this, { + get(target, key, receiver) { + const value = Reflect.get(target, key, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + } + + readPrivateValue() { + return this.#value; + } +} + +export const testNamedHandler = >{ + fetch(request, env, ctx) { + return Response.json({ + source: "testNamedHandler", + method: request.method, + url: request.url, + ctxWaitUntil: typeof ctx.waitUntil, + envKeys: Object.keys(env).sort(), + }); + }, +}; + +export class TestNamedEntrypoint extends WorkerEntrypoint { + fetch(request: Request) { + return Response.json({ + source: "TestNamedEntrypoint", + method: request.method, + url: request.url, + ctxWaitUntil: typeof this.ctx.waitUntil, + envKeys: Object.keys(this.env).sort(), + }); + } + + ping() { + return "pong"; + } + + getCounter() { + return new Counter(0); + } + + async recordFromWorkerEntrypoint(targetName: string, calls: number) { + const id = this.env.TEST_OBJECT.idFromName(targetName); + const stub = this.env.TEST_OBJECT.get(id); + const promises: Promise[] = []; + + for (let i = 0; i < calls; i++) { + promises.push(stub.record(`call-${i}`)); + } + + await Promise.all(promises); + return stub.getLog(); + } +} + +export class TestSuperEntrypoint extends WorkerEntrypoint { + superMethod() { + return "🦸"; + } +} + +let lastController: ScheduledController | undefined; +export default class TestDefaultEntrypoint extends TestSuperEntrypoint { + async fetch(request: Request) { + return Response.json({ + source: "TestDefaultEntrypoint", + method: request.method, + url: request.url, + ctxWaitUntil: typeof this.ctx.waitUntil, + envKeys: Object.keys(this.env).sort(), + }); + } + + async scheduled(controller: ScheduledController) { + lastController = controller; + } + + get lastControllerCron() { + return lastController?.cron; + } + + sum(...args: number[]) { + return args.reduce((acc, value) => acc + value, 0); + } + + backgroundWrite(key: string, value: string) { + this.ctx.waitUntil(this.env.KV_NAMESPACE.put(key, value)); + } + + async read(key: string) { + return this.env.KV_NAMESPACE.get(key); + } + + createCounter(value = 0) { + return new Counter(value); + } + + instanceProperty = "👻"; + instanceMethod = () => "👻"; +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/rpc/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/test/integration-self.test.ts b/fixtures/vitest-pool-workers-examples/rpc/test/integration-self.test.ts new file mode 100644 index 0000000..3b43b88 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/test/integration-self.test.ts @@ -0,0 +1,78 @@ +import { env, exports } from "cloudflare:workers"; +import { it, vi } from "vitest"; + +it("dispatches fetch event", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/"); + expect(await response.json()).toMatchObject({ + ctxWaitUntil: "function", + method: "GET", + source: "TestDefaultEntrypoint", + url: "https://example.com/", + }); +}); + +it("dispatches scheduled event and accesses property with rpc", async ({ + expect, +}) => { + await exports.default.scheduled({ cron: "* * * * 30" }); + const lastControllerCron = await exports.default.lastControllerCron; + expect(lastControllerCron).toBe("* * * * 30"); +}); + +it("calls multi-argument methods with rpc", async ({ expect }) => { + const result = await exports.default.sum(1, 2, 3); + expect(result).toBe(6); +}); + +it("calls methods using ctx and env with rpc", async ({ expect }) => { + expect(await env.KV_NAMESPACE.get("key")).toBe(null); + await exports.default.backgroundWrite("key", "value"); + await vi.waitUntil( + async () => (await env.KV_NAMESPACE.get("key")) === "value" + ); +}); + +it("calls async methods with rpc", async ({ expect }) => { + await env.KV_NAMESPACE.put("key", "value"); + expect(await exports.default.read("key")).toBe("value"); +}); + +it("calls methods with rpc and pipelining", async ({ expect }) => { + const result = await exports.default.createCounter(5).clone().increment(3); + expect(result).toBe(8); +}); + +it("can access methods from superclass", async ({ expect }) => { + const result = await exports.default.superMethod(); + expect(result).toBe("🦸"); +}); +it("cannot access instance properties or methods", async ({ expect }) => { + await expect(async () => await exports.default.instanceProperty).rejects + .toThrowErrorMatchingInlineSnapshot(` + [TypeError: The RPC receiver's prototype does not implement "instanceProperty", but the receiver instance does. + Only properties and methods defined on the prototype can be accessed over RPC. + Ensure properties are declared like \`get instanceProperty() { ... }\` instead of \`instanceProperty = ...\`, + and methods are declared like \`instanceProperty() { ... }\` instead of \`instanceProperty = () => { ... }\`.] + `); + await expect(async () => await exports.default.instanceMethod()).rejects + .toThrowErrorMatchingInlineSnapshot(` + [TypeError: The RPC receiver's prototype does not implement "instanceMethod", but the receiver instance does. + Only properties and methods defined on the prototype can be accessed over RPC. + Ensure properties are declared like \`get instanceMethod() { ... }\` instead of \`instanceMethod = ...\`, + and methods are declared like \`instanceMethod() { ... }\` instead of \`instanceMethod = () => { ... }\`.] + `); +}); +it("cannot access non-existent properties or methods", async ({ expect }) => { + await expect( + // @ts-expect-error intentionally testing incorrect types + async () => await exports.default.nonExistentProperty + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: The RPC receiver does not implement "nonExistentProperty".]` + ); + await expect( + // @ts-expect-error intentionally testing incorrect types + async () => await exports.default.nonExistentMethod() + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: The RPC receiver does not implement "nonExistentMethod".]` + ); +}); diff --git a/fixtures/vitest-pool-workers-examples/rpc/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/rpc/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/test/unit.test.ts b/fixtures/vitest-pool-workers-examples/rpc/test/unit.test.ts new file mode 100644 index 0000000..d22d282 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/test/unit.test.ts @@ -0,0 +1,292 @@ +import { + createExecutionContext, + runDurableObjectAlarm, + runInDurableObject, +} from "cloudflare:test"; +import { env, RpcStub } from "cloudflare:workers"; +import { describe, it, onTestFinished } from "vitest"; +import TestDefaultEntrypoint, { TestObject } from "../src"; +import { Counter } from "../src/counter"; + +describe("named entrypoints", () => { + it("dispatches fetch request to named ExportedHandler", async ({ + expect, + }) => { + const response = await env.TEST_NAMED_HANDLER.fetch("https://example.com"); + expect(await response.json()).toMatchObject({ + ctxWaitUntil: "function", + method: "GET", + source: "testNamedHandler", + url: "https://example.com/", + }); + }); + it("dispatches fetch request to named WorkerEntrypoint", async ({ + expect, + }) => { + const response = await env.TEST_NAMED_ENTRYPOINT.fetch( + "https://example.com" + ); + expect(await response.json()).toMatchObject({ + ctxWaitUntil: "function", + method: "GET", + source: "TestNamedEntrypoint", + url: "https://example.com/", + }); + }); + it("calls method with rpc", async ({ expect }) => { + const result = await env.TEST_NAMED_ENTRYPOINT.ping(); + expect(result).toBe("pong"); + }); + + it("receives RpcTarget over RPC", async ({ expect }) => { + const result = await env.TEST_NAMED_ENTRYPOINT.getCounter(); + expect(await result.value).toBe(0); + result.increment(); + result.increment(); + expect(await result.value).toBe(2); + const counter2 = result.clone(); + counter2.increment(); + expect(await counter2.value).toBe(3); + expect(await result.value).toBe(2); + }); + + it("receives plain objects over RPC", async ({ expect }) => { + const result = await env.TEST_NAMED_ENTRYPOINT.getCounter(); + result.increment(); + expect(await result.asObject()).toMatchObject({ val: 1 }); + }); +}); + +describe("Durable Object", () => { + const orderingAttempts = 5; + const orderingCalls = 100; + + function expectedOrderingLog() { + return Array.from({ length: orderingCalls }, (_, i) => `call-${i}`); + } + function orderingTargetName(prefix: string, attempt: number) { + return `${prefix}-${crypto.randomUUID()}-${attempt}`; + } + + it("dispatches fetch request", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + const response = await stub.fetch("https://example.com"); + expect(await response.json()).toMatchObject({ + ctxWaitUntil: "function", + method: "GET", + source: "TestObject", + url: "https://example.com/", + }); + }); + it("increments count and allows direct/rpc access to instance/storage", async ({ + expect, + }) => { + // Check sending request directly to instance + const id = env.TEST_OBJECT.idFromName("/path"); + const stub = env.TEST_OBJECT.get(id); + const result = await runInDurableObject(stub, (instance: TestObject) => { + expect(instance).toBeInstanceOf(TestObject); // Exact same class as import + return instance.increment(1); + }); + expect(result).toBe(1); + + // Check direct access to properties and storage + await runInDurableObject(stub, async (instance: TestObject, state) => { + expect(instance.value).toBe(1); + expect(await state.storage.get("count")).toBe(1); + }); + + // Check calling method over RPC + expect(await stub.increment(3)).toBe(4); + + // Check accessing property over RPC + expect(await stub.value).toBe(4); + }); + it("dispatches RPC methods from proxy-returning Durable Objects", async ({ + expect, + }) => { + const id = env.PROXIED_TEST_OBJECT.newUniqueId(); + const stub = env.PROXIED_TEST_OBJECT.get(id); + expect(await stub.readPrivateValue()).toBe("private value"); + }); + it("rejects instance overrides of prototype methods", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + await expect( + async () => await stub.overriddenPrototypeMethod() + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: The RPC receiver does not implement the method "overriddenPrototypeMethod".]` + ); + }); + it("immediately executes alarm", async ({ expect }) => { + // Schedule alarm by directly calling method over RPC + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + await stub.increment(3); + await stub.scheduleReset(60_000); + + // Check counter has non-zero value + expect(await stub.value).toBe(3); + + // Immediately execute the alarm to reset the counter + let ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(true); // ...as there was an alarm scheduled + + // Check counter value was reset + expect(await stub.value).toBe(0); + }); + it("cannot access instance properties or methods", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + await expect(async () => await stub.instanceProperty).rejects + .toThrowErrorMatchingInlineSnapshot(` + [TypeError: The RPC receiver's prototype does not implement "instanceProperty", but the receiver instance does. + Only properties and methods defined on the prototype can be accessed over RPC. + Ensure properties are declared like \`get instanceProperty() { ... }\` instead of \`instanceProperty = ...\`, + and methods are declared like \`instanceProperty() { ... }\` instead of \`instanceProperty = () => { ... }\`.] + `); + }); + it("cannot access non-existent properties or methods", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + await expect( + // @ts-expect-error intentionally testing incorrect types + async () => await stub.nonExistentProperty + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: The RPC receiver does not implement "nonExistentProperty".]` + ); + }); + it("receives RpcTarget over RPC", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + using result = await stub.getCounter(); + expect(await result.value).toBe(0); + await result.increment(); + await result.increment(); + expect(await result.value).toBe(2); + // TODO: Improve RPC types so this casting isn't required + using counter2 = (await result.clone()) as unknown as Counter & Disposable; + await counter2.increment(); + expect(await counter2.value).toBe(3); + expect(await result.value).toBe(2); + }); + + it("receives plain objects over RPC", async ({ expect }) => { + const id = env.TEST_OBJECT.newUniqueId(); + const stub = env.TEST_OBJECT.get(id); + using result = await stub.getObject(); + expect(result).toMatchObject({ hello: "world" }); + }); + + // Regression repro for https://github.com/cloudflare/workers-sdk/issues/13433. + it("preserves same-type RPC call order", async ({ expect }) => { + for (let attempt = 0; attempt < orderingAttempts; attempt++) { + const id = env.TEST_OBJECT.idFromName( + orderingTargetName("ordering", attempt) + ); + const stub = env.TEST_OBJECT.get(id); + const promises: Promise[] = []; + + for (let i = 0; i < orderingCalls; i++) { + promises.push(stub.record(`call-${i}`)); + } + + await Promise.all(promises); + + expect(await stub.getLog()).toEqual(expectedOrderingLog()); + } + }); + + it("preserves same-type RPC call order after prewarming instance", async ({ + expect, + }) => { + for (let attempt = 0; attempt < orderingAttempts; attempt++) { + const id = env.TEST_OBJECT.idFromName( + orderingTargetName("prewarmed-ordering", attempt) + ); + const stub = env.TEST_OBJECT.get(id); + await stub.getLog(); + const promises: Promise[] = []; + + for (let i = 0; i < orderingCalls; i++) { + promises.push(stub.record(`call-${i}`)); + } + + await Promise.all(promises); + + expect(await stub.getLog()).toEqual(expectedOrderingLog()); + } + }); + + it("preserves same-type RPC call order from a WorkerEntrypoint caller", async ({ + expect, + }) => { + for (let attempt = 0; attempt < orderingAttempts; attempt++) { + expect( + await env.TEST_NAMED_ENTRYPOINT.recordFromWorkerEntrypoint( + orderingTargetName("worker-entrypoint-ordering", attempt), + orderingCalls + ) + ).toEqual(expectedOrderingLog()); + } + }); + + it("preserves same-type RPC call order from a Durable Object caller", async ({ + expect, + }) => { + for (let attempt = 0; attempt < orderingAttempts; attempt++) { + const id = env.TEST_OBJECT.idFromName( + orderingTargetName("do-caller", attempt) + ); + const stub = env.TEST_OBJECT.get(id); + expect( + await stub.recordFromDurableObject( + orderingTargetName("do-caller-ordering", attempt), + orderingCalls + ) + ).toEqual(expectedOrderingLog()); + } + }); +}); + +// Regression: https://github.com/cloudflare/workers-sdk/issues/7077 +// Fixed in workerd by https://github.com/cloudflare/workerd/pull/3782 +it("can construct a WorkerEntrypoint with mocked env", async ({ expect }) => { + const data = new Map([["mocked-key", "mocked-value"]]); + const mockedKv = new Proxy(env.KV_NAMESPACE, { + get: (target, prop, receiver) => + prop === "get" + ? async (key: string) => data.get(key) ?? null + : Reflect.get(target, prop, receiver), + }); + + const ctx = createExecutionContext(); + const worker = new TestDefaultEntrypoint(ctx, { + ...env, + KV_NAMESPACE: mockedKv, + }); + expect(await worker.read("mocked-key")).toBe("mocked-value"); +}); + +describe("counter", () => { + it("increments count", ({ expect }) => { + const counter = new Counter(3); + expect(counter.increment()).toBe(4); + expect(counter.increment(2)).toBe(6); + expect(counter.value).toBe(6); + }); + it("clones counters", ({ expect }) => { + const counter = new Counter(3); + const clone = counter.clone(); + expect(counter.increment()).toBe(4); + expect(clone.value).toBe(3); + }); + it("calls methods with loopback rpc and pipelining", async ({ expect }) => { + const stub = new RpcStub(new Counter(1)); + // TODO(soon): replace with `using` when supported + onTestFinished(() => stub[Symbol.dispose]()); + const result = await stub.clone().increment(3); + expect(result).toBe(4); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/rpc/tsconfig.json b/fixtures/vitest-pool-workers-examples/rpc/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/rpc/vitest.config.ts b/fixtures/vitest-pool-workers-examples/rpc/vitest.config.ts new file mode 100644 index 0000000..515584f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/vitest.config.ts @@ -0,0 +1,24 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + miniflare: { + // Required to use `exports.default.scheduled()`. This is an experimental + // compatibility flag, and cannot be enabled in production. + compatibilityFlags: [ + "service_binding_extra_handlers", + "nodejs_compat", + ], + }, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/rpc/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/rpc/wrangler.jsonc new file mode 100644 index 0000000..ff8af95 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/rpc/wrangler.jsonc @@ -0,0 +1,45 @@ +{ + "name": "rpc", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "kv_namespaces": [ + { + "binding": "KV_NAMESPACE", + "id": "00000000000000000000000000000000", + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "TEST_OBJECT", + "class_name": "TestObject", + }, + { + "name": "PROXIED_TEST_OBJECT", + "class_name": "ProxiedTestObject", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_classes": ["TestObject"], + }, + { + "tag": "v2", + "new_classes": ["ProxiedTestObject"], + }, + ], + "services": [ + { + "binding": "TEST_NAMED_HANDLER", + "service": "rpc", + "entrypoint": "testNamedHandler", + }, + { + "binding": "TEST_NAMED_ENTRYPOINT", + "service": "rpc", + "entrypoint": "TestNamedEntrypoint", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-examples/tsc-all.mjs b/fixtures/vitest-pool-workers-examples/tsc-all.mjs new file mode 100644 index 0000000..c6bec73 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/tsc-all.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert"; +import childProcess from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import url from "node:url"; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function* walkTsConfigs(rootPath) { + for (const entry of await fs.readdir(rootPath, { withFileTypes: true })) { + if (entry.name === "node_modules") continue; + const filePath = path.join(rootPath, entry.name); + if (entry.isDirectory()) yield* walkTsConfigs(filePath); + else if (entry.name === "tsconfig.json") yield filePath; + } +} + +assert( + process.env.PATH?.includes(path.join(__dirname, "node_modules/.bin")), + "Expected `tsc-all.mjs` to be run with `pnpm check:type`" +); + +for await (const tsconfigPath of walkTsConfigs(__dirname)) { + console.log(`Checking ${path.relative(__dirname, tsconfigPath)}...`); + const result = childProcess.spawnSync("tsc", ["-p", tsconfigPath], { + stdio: "inherit", + cwd: __dirname, + shell: true, + }); + if (result.status !== 0) process.exitCode = 1; +} diff --git a/fixtures/vitest-pool-workers-examples/tsconfig.node.json b/fixtures/vitest-pool-workers-examples/tsconfig.node.json new file mode 100644 index 0000000..c071db3 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "module": "esnext", + "target": "esnext", + "lib": ["esnext"], + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true + } +} diff --git a/fixtures/vitest-pool-workers-examples/tsconfig.workerd-test.json b/fixtures/vitest-pool-workers-examples/tsconfig.workerd-test.json new file mode 100644 index 0000000..3aa2158 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/tsconfig.workerd-test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "types": [ + "@cloudflare/workers-types/experimental", + "@cloudflare/vitest-pool-workers/types", + "@types/node", // For tests using Node.js compat APIs (process, Buffer, node:*) + "vite/client" // For `?raw`, `?url`, etc. import types + ] + } +} diff --git a/fixtures/vitest-pool-workers-examples/tsconfig.workerd.json b/fixtures/vitest-pool-workers-examples/tsconfig.workerd.json new file mode 100644 index 0000000..b827fd6 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/tsconfig.workerd.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "skipLibCheck": false, + "types": ["@cloudflare/workers-types/experimental"] + } +} diff --git a/fixtures/vitest-pool-workers-examples/vitest.config.ts b/fixtures/vitest-pool-workers-examples/vitest.config.ts new file mode 100644 index 0000000..b292e26 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/vitest.config.ts @@ -0,0 +1,22 @@ +// Root vitest config for the vitest-pool-workers-examples fixture. +// Per the Vitest 4 docs, only `globalSetup`, `reporters`, `coverage`, and +// other "global" options are inherited from this root config; project test +// options (testTimeout, retry, etc.) are NOT inherited. Each project under +// `*/vitest.*config.*ts` extends `vitest.shared.ts` directly via mergeConfig. +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + reporters: ["default"], + projects: [ + "*/vitest.*config.*ts", + // workerd's Windows SQLite VFS uses kj::Path::toString() (Unix-style + // paths) with the win32 VFS, causing SQLITE_CANTOPEN for disk-backed + // SQLite DOs. Exclude until workerd ships the fix (cloudflare/workerd#6110). + ...(process.platform === "win32" + ? ["!durable-objects/vitest.*config.*ts"] + : []), + ], + globalSetup: ["./vitest.global.ts"], + }, +}); diff --git a/fixtures/vitest-pool-workers-examples/vitest.global.ts b/fixtures/vitest-pool-workers-examples/vitest.global.ts new file mode 100644 index 0000000..bfabe36 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/vitest.global.ts @@ -0,0 +1,3 @@ +export function setup(): void { + process.env.TZ = "UTC"; +} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/README.md b/fixtures/vitest-pool-workers-examples/web-assembly/README.md new file mode 100644 index 0000000..c101816 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/README.md @@ -0,0 +1,7 @@ +# ⚙️ web-assembly + +This Worker makes use of a WebAssembly module to add two numbers together. Wrangler's default module rules are enabled by `@cloudflare/vitest-pool-workers` if you have a `wrangler.configPath` configured. This means `.wasm` files can be imported as `WebAssembly.Module`s. + +| Test | Overview | +| ------------------------------- | -------------------------------- | +| [add.test.ts](test/add.test.ts) | Integration test using `exports` | diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/src/add.wasm b/fixtures/vitest-pool-workers-examples/web-assembly/src/add.wasm new file mode 100644 index 0000000..fa370ae Binary files /dev/null and b/fixtures/vitest-pool-workers-examples/web-assembly/src/add.wasm differ diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/src/env.d.ts b/fixtures/vitest-pool-workers-examples/web-assembly/src/env.d.ts new file mode 100644 index 0000000..5e3887c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/src/env.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./web-assembly/src/env.d.ts -c ./web-assembly/wrangler.jsonc --no-include-runtime` (hash: 0c8cd5ae12176ebab8337cbfef98b516) +interface __BaseEnv_Env {} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/src/index.ts b/fixtures/vitest-pool-workers-examples/web-assembly/src/index.ts new file mode 100644 index 0000000..1a5c49a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/src/index.ts @@ -0,0 +1,25 @@ +import addModule from "./add.wasm"; +// Verify that we can also load this with "?module". +// Generated Prisma clients can include this import style as an example. +import addModule2 from "./add.wasm?module"; + +const addInstance = new WebAssembly.Instance(addModule); +const add = addInstance.exports.add as (a: number, b: number) => number; + +const add2Instance = new WebAssembly.Instance(addModule2); +const add2 = add2Instance.exports.add as (a: number, b: number) => number; + +export default { + fetch(request, env, ctx) { + const url = new URL(request.url); + const a = parseInt(url.searchParams.get("a") ?? "0"); + const b = parseInt(url.searchParams.get("b") ?? "0"); + const result = add(a, b); + + if (result !== add2(a, b)) { + return new Response("add mismatch"); + } + + return new Response(result.toString()); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/web-assembly/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/src/wasm-shim.d.ts b/fixtures/vitest-pool-workers-examples/web-assembly/src/wasm-shim.d.ts new file mode 100644 index 0000000..ee84755 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/src/wasm-shim.d.ts @@ -0,0 +1,9 @@ +declare module "*.wasm" { + const value: WebAssembly.Module; + export default value; +} + +declare module "*.wasm?module" { + const value: WebAssembly.Module; + export default value; +} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/test/add.test.ts b/fixtures/vitest-pool-workers-examples/web-assembly/test/add.test.ts new file mode 100644 index 0000000..e5dcd0c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/test/add.test.ts @@ -0,0 +1,7 @@ +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +it("adds numbers together", async ({ expect }) => { + const response = await exports.default.fetch("https://example.com/?a=1&b=2"); + expect(await response.text()).toBe("3"); +}); diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/test/not-actually.wasm.test.ts b/fixtures/vitest-pool-workers-examples/web-assembly/test/not-actually.wasm.test.ts new file mode 100644 index 0000000..36eb0f7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/test/not-actually.wasm.test.ts @@ -0,0 +1,10 @@ +// Regression test for https://github.com/cloudflare/workers-sdk/issues/8280 +// A test file whose name contains ".wasm" (but whose actual extension is .ts) +// must NOT be treated as a WebAssembly module by the module rules. +import { it } from "vitest"; + +it("loads .wasm.test.ts files as JavaScript, not as WebAssembly", ({ + expect, +}) => { + expect(true).toBe(true); +}); diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/web-assembly/test/tsconfig.json new file mode 100644 index 0000000..ff294d1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts", "../src/wasm-shim.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/tsconfig.json b/fixtures/vitest-pool-workers-examples/web-assembly/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/vitest.config.ts b/fixtures/vitest-pool-workers-examples/web-assembly/vitest.config.ts new file mode 100644 index 0000000..f8b2e2b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/vitest.config.ts @@ -0,0 +1,20 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + // Specifying a `wrangler.configPath` will enable Wrangler's default + // module rules including support for `.wasm` files. Refer to + // https://developers.cloudflare.com/workers/wrangler/bundling/#files-which-will-not-be-bundled + // for more information. + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/web-assembly/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/web-assembly/wrangler.jsonc new file mode 100644 index 0000000..a604e80 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/web-assembly/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "web-assembly", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/env.d.ts b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/env.d.ts new file mode 100644 index 0000000..72d663b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./workers-assets-no-dir/src/env.d.ts -c ./workers-assets-no-dir/wrangler.jsonc --no-include-runtime` (hash: 1177c209bb457d419779798c493147d2) +interface __BaseEnv_Env { + ASSETS: Fetcher; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/index.ts b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/index.ts new file mode 100644 index 0000000..eefdc49 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/index.ts @@ -0,0 +1,5 @@ +export default { + async fetch(request): Promise { + return new Response("Hello from worker!"); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/assets-no-dir.test.ts b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/assets-no-dir.test.ts new file mode 100644 index 0000000..9e1ab2c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/assets-no-dir.test.ts @@ -0,0 +1,19 @@ +import { exports } from "cloudflare:workers"; +import { describe, it } from "vitest"; + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/9381 +// +// When a wrangler config has `assets: { binding: "ASSETS" }` but no `directory` +// (as is common when using @cloudflare/vite-plugin, which handles asset serving +// independently via its dev server), vitest-pool-workers must not throw: +// "The `assets` property in your configuration is missing the required `directory` property." + +describe("workers-assets-no-dir", () => { + it("worker starts and responds when assets binding has no directory configured", async ({ + expect, + }) => { + const response = await exports.default.fetch("http://example.com/"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello from worker!"); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/vitest.config.ts b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/vitest.config.ts new file mode 100644 index 0000000..d9a889a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/wrangler.jsonc new file mode 100644 index 0000000..49d877d --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets-no-dir/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "name": "workers-assets-no-directory", + "main": "src/index.ts", + // Simulate a project using @cloudflare/vite-plugin where assets are configured + // with a binding but no directory (vite-plugin serves them via its dev server). + // vitest-pool-workers must not throw "missing required `directory` property" here. + "assets": { + "binding": "ASSETS", + }, +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/README.md b/fixtures/vitest-pool-workers-examples/workers-assets/README.md new file mode 100644 index 0000000..6b5ab16 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/README.md @@ -0,0 +1,5 @@ +# ✅ workers-static-assets-with-user-worker + +This Worker contains assets as well as a Worker script + +Neither integration nor unit tests should expose assets. However, you can mock them (demonstrated in this example), or write an integration test using [`unstable_startWorker()`](https://developers.cloudflare.com/workers/testing/unstable_startworker/). diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/public/binding.html b/fixtures/vitest-pool-workers-examples/workers-assets/public/binding.html new file mode 100644 index 0000000..7f4f36e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/public/binding.html @@ -0,0 +1 @@ +

binding.html

diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/public/index.html b/fixtures/vitest-pool-workers-examples/workers-assets/public/index.html new file mode 100644 index 0000000..29df18b --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/public/index.html @@ -0,0 +1,11 @@ + + + + + + Hello, World! + + +

Asset index.html

+ + diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/src/env.d.ts b/fixtures/vitest-pool-workers-examples/workers-assets/src/env.d.ts new file mode 100644 index 0000000..e29b455 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/src/env.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./workers-assets/src/env.d.ts -c ./workers-assets/wrangler.jsonc --no-include-runtime` (hash: 1177c209bb457d419779798c493147d2) +interface __BaseEnv_Env { + ASSETS: Fetcher; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/src/index.ts b/fixtures/vitest-pool-workers-examples/workers-assets/src/index.ts new file mode 100644 index 0000000..62d1b4a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/src/index.ts @@ -0,0 +1,15 @@ +export default { + async fetch(request, env, ctx): Promise { + const url = new URL(request.url); + switch (url.pathname) { + case "/message": + return new Response("Hello, World!"); + case "/random": + return new Response(crypto.randomUUID()); + case "/binding": + return env.ASSETS.fetch(request); + default: + return new Response(null, { status: 404 }); + } + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/test/assets.test.ts b/fixtures/vitest-pool-workers-examples/workers-assets/test/assets.test.ts new file mode 100644 index 0000000..4b8e508 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/test/assets.test.ts @@ -0,0 +1,34 @@ +import { env, exports, withEnv } from "cloudflare:workers"; +import { describe, it } from "vitest"; +import worker from "../src"; + +describe("Hello World user worker", () => { + it('responds with "Hello, World!" (integration style)', async ({ + expect, + }) => { + const response = await exports.default.fetch("http://example.com/message"); + expect(await response.text()).toMatchInlineSnapshot(`"Hello, World!"`); + }); + + it("returns 404 for unknown routes (assets are not routed through exports.default)", async ({ + expect, + }) => { + const response = await exports.default.fetch("http://example.com/"); + expect(response.status).toBe(404); + }); + + it("can mock the ASSETS binding using withEnv", async ({ expect }) => { + const mockAssets = { + fetch: async () => new Response("mocked asset response"), + }; + + await withEnv({ ...env, ASSETS: mockAssets }, async () => { + const response = await worker.fetch( + new Request("http://example.com/binding"), + env, + {} as ExecutionContext + ); + expect(await response.text()).toBe("mocked asset response"); + }); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/tsconfig.json b/fixtures/vitest-pool-workers-examples/workers-assets/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/vitest.config.ts b/fixtures/vitest-pool-workers-examples/workers-assets/vitest.config.ts new file mode 100644 index 0000000..d9a889a --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/workers-assets/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/workers-assets/wrangler.jsonc new file mode 100644 index 0000000..a7babfd --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workers-assets/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "name": "workers-static-assets-with-user-worker", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "assets": { + "directory": "./public", + "binding": "ASSETS", + "html_handling": "auto-trailing-slash", + "not_found_handling": "none", + }, +} diff --git a/fixtures/vitest-pool-workers-examples/workflows/README.md b/fixtures/vitest-pool-workers-examples/workflows/README.md new file mode 100644 index 0000000..bf98b73 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/README.md @@ -0,0 +1,9 @@ +# 🔁 workflows + +This Worker includes a ModeratorWorkflow that serves as a template for an automated content moderation process. +The testing suite uses workflow mocking to validate the logic of each step. + +| Test | Overview | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [integration.test.ts](test/integration.test.ts) | Tests on the Worker's endpoints, ensuring that workflows are created and run correctly. | +| [unit.test.ts](test/unit.test.ts) | Tests on the internal logic of each workflow. It uses mocking to test steps in isolation. | diff --git a/fixtures/vitest-pool-workers-examples/workflows/src/env.d.ts b/fixtures/vitest-pool-workers-examples/workflows/src/env.d.ts new file mode 100644 index 0000000..a76ffa1 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/src/env.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types ./workflows/src/env.d.ts -c ./workflows/wrangler.jsonc --no-include-runtime` (hash: 84529125ee80eb4b92700c4efdce1e33) +interface __BaseEnv_Env { + MODERATOR: Workflow< + Parameters[0]["payload"] + >; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-examples/workflows/src/index.ts b/fixtures/vitest-pool-workers-examples/workflows/src/index.ts new file mode 100644 index 0000000..7f753b4 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/src/index.ts @@ -0,0 +1,89 @@ +import { + WorkflowEntrypoint, + WorkflowEvent, + WorkflowStep, +} from "cloudflare:workers"; + +export class ModeratorWorkflow extends WorkflowEntrypoint { + async run(_event: Readonly>, step: WorkflowStep) { + await step.sleep("sleep for a while", "10 seconds"); + + // Get an initial analysis from an AI model + const aiResult = await step.do("AI content scan", async () => { + // Call to an workers-ai to scan the text content and return a violation score + + // Simulated score: + const violationScore = Math.floor(Math.random() * 100); + + return { violationScore: violationScore }; + }); + + // Triage based on the AI score + if (aiResult.violationScore < 10) { + await step.do("auto approve content", async () => { + // API call to set app content status to "approved" + return { status: "auto_approved" }; + }); + return { status: "auto_approved" }; + } + if (aiResult.violationScore > 90) { + await step.do("auto reject content", async () => { + // API call to set app content status to "rejected" + return { status: "auto_rejected" }; + }); + return { status: "auto_rejected" }; + } + + // If the score is ambiguous, require human review + type EventPayload = { + moderatorAction: string; + }; + const eventPayload = await step.waitForEvent("human review", { + type: "moderation-decision", + timeout: "1 day", + }); + + if (eventPayload) { + // The moderator responded in time. + const decision = eventPayload.payload.moderatorAction; // e.g., "approve" or "reject" + await step.do("apply moderator decision", async () => { + // API call to update content status based on the decision + return { status: "moderated", decision: decision }; + }); + return { status: "moderated", decision: decision }; + } + } +} + +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + const maybeId = url.searchParams.get("id"); + if (maybeId !== null) { + const instance = await env.MODERATOR.get(maybeId); + + return Response.json(await instance.status()); + } + + if (url.pathname === "/moderate") { + const workflow = await env.MODERATOR.create(); + return Response.json({ + id: workflow.id, + details: await workflow.status(), + }); + } + + if (url.pathname === "/moderate-batch") { + const workflows = await env.MODERATOR.createBatch([ + {}, + { id: "321" }, + {}, + ]); + + const ids = workflows.map((workflow) => workflow.id); + return Response.json({ ids: ids }); + } + + return new Response("Not found", { status: 404 }); + }, +}; diff --git a/fixtures/vitest-pool-workers-examples/workflows/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/workflows/src/tsconfig.json new file mode 100644 index 0000000..0141323 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workflows/test/integration.test.ts b/fixtures/vitest-pool-workers-examples/workflows/test/integration.test.ts new file mode 100644 index 0000000..3254819 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/test/integration.test.ts @@ -0,0 +1,174 @@ +import { introspectWorkflow } from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { describe, it } from "vitest"; + +const STATUS_COMPLETE = "complete"; +const STEP_NAME = "AI content scan"; +const mockResult = { violationScore: 0 }; + +// This example implicitly disposes the Workflow instance +it("workflow should be able to reach the end and be successful", async ({ + expect, +}) => { + // CONFIG with `await using` to ensure Workflow instances cleanup: + await using introspector = await introspectWorkflow(env.MODERATOR); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await exports.default.fetch(`https://mock-worker.local/moderate`); + + const instances = await introspector.get(); + expect(instances.length).toBe(1); + + // ASSERTIONS: + const instance = instances[0]; + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_approved" }); + + // DISPOSE: ensured by `await using` +}); + +// This example explicitly disposes the Workflow instances +it("workflow batch should be able to reach the end and be successful", async ({ + expect, +}) => { + // CONFIG: + let introspector = await introspectWorkflow(env.MODERATOR); + try { + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await exports.default.fetch(`https://mock-worker.local/moderate-batch`); + + const instances = await introspector.get(); + expect(instances.length).toBe(3); + + // ASSERTIONS: + for (const instance of instances) { + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + await expect( + instance.waitForStatus(STATUS_COMPLETE) + ).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_approved" }); + } + } finally { + // DISPOSE: + // Workflow introspector should be disposed the end of each test, if no `await using` dyntax is used + // Also disposes all intercepted instances + await introspector.dispose(); + } +}); + +describe("workflow instance lifecycle methods", () => { + it("should terminate a workflow instance", async ({ expect }) => { + // CONFIG: + await using introspector = await introspectWorkflow(env.MODERATOR); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, { violationScore: 50 }); + }); + + const res = await exports.default.fetch( + "https://mock-worker.local/moderate" + ); + const data = (await res.json()) as { id: string; details: unknown }; + + const instances = await introspector.get(); + expect(instances.length).toBe(1); + const instance = instances[0]; + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual({ + violationScore: 50, + }); + + const handle = await env.MODERATOR.get(data.id); + await handle.terminate(); + + // ASSERTIONS: + await expect(instance.waitForStatus("terminated")).resolves.not.toThrow(); + + // DISPOSE: ensured by `await using` + }); + + it("should restart a workflow instance", async ({ expect }) => { + // CONFIG: + await using introspector = await introspectWorkflow(env.MODERATOR); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, { violationScore: 50 }); + await m.mockEvent({ + type: "moderation-decision", + payload: { moderatorAction: "approve" }, + }); + }); + + const res = await exports.default.fetch( + "https://mock-worker.local/moderate" + ); + const data = (await res.json()) as { id: string; details: unknown }; + + const instances = await introspector.get(); + expect(instances.length).toBe(1); + const instance = instances[0]; + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual({ + violationScore: 50, + }); + + const handle = await env.MODERATOR.get(data.id); + await handle.restart(); + + // Mocks survive instace restart, so the restarted workflow re-runs + // with the same config + await expect( + instance.waitForStatus(STATUS_COMPLETE) + ).resolves.not.toThrow(); + + // DISPOSE: ensured by `await using` + }); + + // TODO(vaish): add restart-from-step test here once @cloudflare/workers-types ships restart options + + it("should pause a workflow instance", async ({ expect }) => { + // CONFIG: + await using introspector = await introspectWorkflow(env.MODERATOR); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, { violationScore: 50 }); + }); + + const res = await exports.default.fetch( + "https://mock-worker.local/moderate" + ); + const data = (await res.json()) as { id: string; details: unknown }; + + const instances = await introspector.get(); + expect(instances.length).toBe(1); + const instance = instances[0]; + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual({ + violationScore: 50, + }); + + const handle = await env.MODERATOR.get(data.id); + await handle.pause(); + + // ASSERTIONS: + await expect(instance.waitForStatus("paused")).resolves.not.toThrow(); + + // DISPOSE: ensured by `await using` + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/workflows/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/workflows/test/tsconfig.json new file mode 100644 index 0000000..49d6632 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workflows/test/unit.test.ts b/fixtures/vitest-pool-workers-examples/workflows/test/unit.test.ts new file mode 100644 index 0000000..58f33bc --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/test/unit.test.ts @@ -0,0 +1,217 @@ +import { introspectWorkflowInstance } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { it } from "vitest"; + +const STATUS_COMPLETE = "complete"; +const STATUS_ERROR = "errored"; +const STEP_NAME = "AI content scan"; + +// This example implicitly disposes the Workflow instance +it("should mock a non-violation score and complete", async ({ expect }) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 0 }; + + // CONFIG with `await using` to ensure Workflow instances cleanup: + await using instance = await introspectWorkflowInstance( + env.MODERATOR, + instanceId + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + // ASSERTIONS: + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + expect( + await instance.waitForStepResult({ name: "auto approve content" }) + ).toEqual({ status: "auto_approved" }); + + await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_approved" }); + + // DISPOSE: ensured by `await using` +}); + +// This example explicitly disposes the Workflow instance +it("should mock the violation score calculation to fail 2 times and then complete", async ({ + expect, +}) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 0 }; + + // CONFIG: + const instance = await introspectWorkflowInstance(env.MODERATOR, instanceId); + + try { + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepError( + { name: STEP_NAME }, + new Error("Something went wrong!"), + 2 + ); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + // ASSERTIONS: + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + expect( + await instance.waitForStepResult({ name: "auto approve content" }) + ).toEqual({ status: "auto_approved" }); + + await expect( + instance.waitForStatus(STATUS_COMPLETE) + ).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_approved" }); + } finally { + // DISPOSE: + // Workflow introspector should be disposed the end of each test, if no `await using` dyntax is used + // Also disposes all intercepted instances + await instance.dispose(); + } +}); + +it("should mock a violation score and complete", async ({ expect }) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 99 }; + + await using instance = await introspectWorkflowInstance( + env.MODERATOR, + instanceId + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + expect( + await instance.waitForStepResult({ name: "auto reject content" }) + ).toEqual({ status: "auto_rejected" }); + + await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_rejected" }); +}); + +it("should be reviewed, accepted and complete", async ({ expect }) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 50 }; + + await using instance = await introspectWorkflowInstance( + env.MODERATOR, + instanceId + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + await m.mockEvent({ + type: "moderation-decision", + payload: { moderatorAction: "approve" }, + }); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + expect( + await instance.waitForStepResult({ name: "apply moderator decision" }) + ).toEqual({ status: "moderated", decision: "approve" }); + + await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ decision: "approve", status: "moderated" }); +}); + +it("should disable retry delays when mocking step errors", async ({ + expect, +}) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 0 }; + + await using instance = await introspectWorkflowInstance( + env.MODERATOR, + instanceId + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.disableRetryDelays(); + await m.mockStepError( + { name: STEP_NAME }, + new Error("Transient failure"), + 2 + ); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + + await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow(); + + const output = await instance.getOutput(); + expect(output).toEqual({ status: "auto_approved" }); +}); + +it("should force human review to timeout and error", async ({ expect }) => { + const instanceId = crypto.randomUUID(); + const mockResult = { violationScore: 50 }; + + await using instance = await introspectWorkflowInstance( + env.MODERATOR, + instanceId + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: STEP_NAME }, mockResult); + await m.forceEventTimeout({ name: "human review" }); + }); + + await env.MODERATOR.create({ + id: instanceId, + }); + + expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual( + mockResult + ); + + await expect(instance.waitForStatus(STATUS_ERROR)).resolves.not.toThrow(); + + const error = await instance.getError(); + expect(error.name).toEqual("Error"); + expect(error.message).toContain("Execution timed out"); +}); diff --git a/fixtures/vitest-pool-workers-examples/workflows/tsconfig.json b/fixtures/vitest-pool-workers-examples/workflows/tsconfig.json new file mode 100644 index 0000000..90e58bf --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/workflows/vitest.config.ts b/fixtures/vitest-pool-workers-examples/workflows/vitest.config.ts new file mode 100644 index 0000000..4217062 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/vitest.config.ts @@ -0,0 +1,16 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + }) +); diff --git a/fixtures/vitest-pool-workers-examples/workflows/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/workflows/wrangler.jsonc new file mode 100644 index 0000000..df0fe05 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/workflows/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "workflows", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "workflows": [ + { + "binding": "MODERATOR", + "class_name": "ModeratorWorkflow", + "name": "moderator-workflow", + }, + ], +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/env.d.ts b/fixtures/vitest-pool-workers-remote-bindings/env.d.ts new file mode 100644 index 0000000..c9d4878 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/env.d.ts @@ -0,0 +1,4 @@ +declare module "cloudflare:workers" { + // ProvidedEnv controls the type of `import("cloudflare:workers").env` + interface ProvidedEnv extends Env {} +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/package.json b/fixtures/vitest-pool-workers-remote-bindings/package.json new file mode 100644 index 0000000..72ac0b9 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixture/vitest-pool-workers-remote-bindings", + "private": true, + "type": "module", + "scripts": { + "cf-typegen": "wrangler types --no-include-runtime", + "test": "vitest run --config vitest.workers.config.ts", + "test:ci": "node run-tests.mjs", + "test:vitest": "vitest run" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "workspace:*", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/remote-worker.js b/fixtures/vitest-pool-workers-remote-bindings/remote-worker.js new file mode 100644 index 0000000..3e6f023 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/remote-worker.js @@ -0,0 +1,7 @@ +export default { + fetch() { + return new Response( + "Hello from a remote Worker part of the vitest-pool-workers remote bindings fixture!" + ); + }, +}; diff --git a/fixtures/vitest-pool-workers-remote-bindings/remote-worker.staging.js b/fixtures/vitest-pool-workers-remote-bindings/remote-worker.staging.js new file mode 100644 index 0000000..2857097 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/remote-worker.staging.js @@ -0,0 +1,7 @@ +export default { + fetch() { + return new Response( + "Hello from a remote Worker, defined for the staging environment, part of the vitest-pool-workers remote bindings fixture!" + ); + }, +}; diff --git a/fixtures/vitest-pool-workers-remote-bindings/run-tests.mjs b/fixtures/vitest-pool-workers-remote-bindings/run-tests.mjs new file mode 100644 index 0000000..72c90aa --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/run-tests.mjs @@ -0,0 +1,200 @@ +/** + * This fixture is particular since it needs to communicate with remote resources, namely + * two remote workers. + * + * This script is used to deploy the remote workers and run the fixture using said workers + * with the appropriate vitest configurations. + */ +import assert from "node:assert"; +import { execSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { cpSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { setTimeout } from "node:timers/promises"; + +const env = getAuthenticatedEnv(); +if (!env) { + console.warn("No credentials provided, skipping test..."); + process.exit(0); +} + +rmSync("./.tmp", { recursive: true, force: true }); + +cpSync("./src", "./.tmp/src", { recursive: true }); +cpSync("./test", "./.tmp/test", { recursive: true }); +cpSync("./vitest.workers.config.ts", "./.tmp/vitest.workers.config.ts"); +cpSync( + "./vitest.workers.config.staging.ts", + "./.tmp/vitest.workers.config.staging.ts" +); + +const remoteWorkerName = `tmp-e2e-worker-test-remote-bindings-${ + randomUUID().split("-")[0] +}`; +const remoteStagingWorkerName = `tmp-e2e-staging-worker-test-remote-bindings-${ + randomUUID().split("-")[0] +}`; + +const wranglerJson = JSON.parse(readFileSync("./wrangler.json", "utf8")); +wranglerJson.services[0].service = remoteWorkerName; +wranglerJson.env.staging.services[0].service = remoteStagingWorkerName; + +writeFileSync( + "./.tmp/wrangler.json", + JSON.stringify(wranglerJson, undefined, 2), + "utf8" +); + +writeFileSync( + "./.tmp/remote-wrangler.json", + JSON.stringify( + { + name: remoteWorkerName, + main: "../remote-worker.js", + compatibility_date: "2025-06-01", + }, + undefined, + 2 + ), + "utf8" +); + +const deployOut = execSync( + "pnpm wrangler deploy -c .tmp/remote-wrangler.json", + { + stdio: "pipe", + cwd: "./.tmp", + env, + encoding: "utf8", + } +); + +if (!new RegExp(`Deployed\\s+${remoteWorkerName}\\b`).test(`${deployOut}`)) { + throw new Error(`Failed to deploy ${remoteWorkerName}`); +} + +const urlMatcher = new RegExp( + `(?https:\\/\\/${remoteWorkerName}\\..+?\\.workers\\.dev)` +); + +const deployedUrl = deployOut.match(urlMatcher)?.groups?.url; +assert(deployedUrl, "Failed to find deployed worker URL"); + +writeFileSync( + "./.tmp/remote-wrangler.staging.json", + JSON.stringify( + { + name: remoteStagingWorkerName, + main: "../remote-worker.staging.js", + compatibility_date: "2025-06-01", + }, + undefined, + 2 + ), + "utf8" +); + +const deployStagingOut = execSync( + "pnpm wrangler deploy -c .tmp/remote-wrangler.staging.json", + { + stdio: "pipe", + cwd: "./.tmp", + env, + encoding: "utf8", + } +); +if ( + !new RegExp(`Deployed\\s+${remoteStagingWorkerName}\\b`).test( + `${deployStagingOut}` + ) +) { + throw new Error(`Failed to deploy ${remoteStagingWorkerName}`); +} +const deployedStagingUrl = deployStagingOut.match( + /(?https:\/\/tmp-e2e-.+?\..+?\.workers\.dev)/ +)?.groups?.url; +assert(deployedStagingUrl, "Failed to find deployed staging worker URL"); + +await setTimeout(2000); + +// Wait for the workers to become available +await Promise.all([ + waitFor(async () => { + const response = await fetch(deployedUrl); + assert.equal(response.status, 200); + }), + waitFor(async () => { + const response = await fetch(deployedStagingUrl); + assert.equal(response.status, 200); + }), +]); + +try { + try { + try { + console.log("Running vitest-pool-workers remote bindings tests..."); + execSync("pnpm test:vitest --config ./.tmp/vitest.workers.config.ts", { + env, + stdio: "inherit", + }); + console.log( + "Running vitest-pool-workers remote bindings staging tests..." + ); + execSync( + "pnpm test:vitest --config ./.tmp/vitest.workers.config.staging.ts", + { + env, + stdio: "inherit", + } + ); + } finally { + execSync(`pnpm wrangler delete --name ${remoteWorkerName}`, { env }); + } + } finally { + execSync(`pnpm wrangler delete --name ${remoteStagingWorkerName}`, { env }); + } +} finally { + rmSync("./.tmp", { recursive: true, force: true }); +} + +/** + * Gets an env object containing Cloudflare credentials or undefined if not authenticated. + * + * In the Github actions we convert the TEST_CLOUDFLARE_ACCOUNT_ID and TEST_CLOUDFLARE_API_TOKEN env variables. + * In local development we can rely on CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN env variables directly. + */ +function getAuthenticatedEnv() { + const CLOUDFLARE_ACCOUNT_ID = + process.env.TEST_CLOUDFLARE_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID; + const CLOUDFLARE_API_TOKEN = + process.env.TEST_CLOUDFLARE_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN; + + if (CLOUDFLARE_ACCOUNT_ID && CLOUDFLARE_API_TOKEN) { + return { + ...process.env, + CLOUDFLARE_API_TOKEN, + CLOUDFLARE_ACCOUNT_ID, + }; + } + console.warn( + "Skipping vitest-pool-workers remote bindings tests because the environment is not authenticated with Cloudflare." + ); +} + +/** + * Wait for the callback to execute successfully. + * + * If the callback throws an error or returns a rejected promise it will continue to wait until it succeeds or times out. + */ +export async function waitFor(callback) { + const start = Date.now(); + while (true) { + try { + return callback(); + } catch (error) { + if (Date.now() < start + 15_000) { + throw error; + } + } + await setTimeout(1000); + } +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/src/index.ts b/fixtures/vitest-pool-workers-remote-bindings/src/index.ts new file mode 100644 index 0000000..2316494 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/src/index.ts @@ -0,0 +1,7 @@ +export default { + async fetch(request, env) { + const remoteWorkerResp = await env.MY_WORKER.fetch(request); + const remoteWorkerRespText = await remoteWorkerResp.text(); + return new Response(`Response from remote worker: ${remoteWorkerRespText}`); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-remote-bindings/test-staging/index.spec.ts b/fixtures/vitest-pool-workers-remote-bindings/test-staging/index.spec.ts new file mode 100644 index 0000000..5af2c12 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/test-staging/index.spec.ts @@ -0,0 +1,30 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { describe, test } from "vitest"; + +describe("Vitest pool workers remote bindings with a staging environment", () => { + test( + "fetching unit-style from a remote service binding", + { timeout: 50_000 }, + async ({ expect }) => { + const response = await env.MY_WORKER.fetch("http://example.com"); + const ctx = createExecutionContext(); + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot( + `"Hello from a remote Worker, defined for the staging environment, part of the vitest-pool-workers remote bindings fixture!"` + ); + } + ); + + test("fetching integration-style from the local worker (which uses remote bindings)", async ({ + expect, + }) => { + const response = await exports.default.fetch("https://example.com"); + expect(await response.text()).toMatchInlineSnapshot( + `"Response from remote worker: Hello from a remote Worker, defined for the staging environment, part of the vitest-pool-workers remote bindings fixture!"` + ); + }); +}); diff --git a/fixtures/vitest-pool-workers-remote-bindings/test/index.spec.ts b/fixtures/vitest-pool-workers-remote-bindings/test/index.spec.ts new file mode 100644 index 0000000..7e3206d --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/test/index.spec.ts @@ -0,0 +1,30 @@ +import { + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { env, exports } from "cloudflare:workers"; +import { describe, test } from "vitest"; + +describe("Vitest pool workers remote bindings", () => { + test( + "fetching unit-style from a remote service binding", + { timeout: 50_000 }, + async ({ expect }) => { + const response = await env.MY_WORKER.fetch("http://example.com"); + const ctx = createExecutionContext(); + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot( + `"Hello from a remote Worker part of the vitest-pool-workers remote bindings fixture!"` + ); + } + ); + + test("fetching integration-style from the local worker (which uses remote bindings)", async ({ + expect, + }) => { + const response = await exports.default.fetch("https://example.com"); + expect(await response.text()).toMatchInlineSnapshot( + `"Response from remote worker: Hello from a remote Worker part of the vitest-pool-workers remote bindings fixture!"` + ); + }); +}); diff --git a/fixtures/vitest-pool-workers-remote-bindings/tsconfig.json b/fixtures/vitest-pool-workers-remote-bindings/tsconfig.json new file mode 100644 index 0000000..67d8573 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [ + "./worker-configuration.d.ts", + "@cloudflare/vitest-pool-workers/types" + ], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["src", "env.d.ts", "test", "test-staging"] +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/turbo.json b/fixtures/vitest-pool-workers-remote-bindings/turbo.json new file mode 100644 index 0000000..789f1de --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/turbo.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test:ci": { + "env": [ + "VITEST", + "NODE_DEBUG", + "MINIFLARE_WORKERD_PATH", + "WRANGLER", + "WRANGLER_IMPORT", + "MINIFLARE_IMPORT", + "TEST_CLOUDFLARE_ACCOUNT_ID", + "TEST_CLOUDFLARE_API_TOKEN", + "WRANGLER_E2E_TEST_FILE" + ] + } + } +} diff --git a/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.staging.ts b/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.staging.ts new file mode 100644 index 0000000..8a8c9e6 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.staging.ts @@ -0,0 +1,22 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.json", environment: "staging" }, + }), + ], + test: { + reporters: ["default"], + include: ["test-staging/**/*.spec.ts"], + server: { + deps: { + external: [ + /packages\/vitest-pool-workers\/dist/, + /packages\/wrangler\//, + ], + }, + }, + }, +}); diff --git a/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.ts b/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.ts new file mode 100644 index 0000000..38e9fcd --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/vitest.workers.config.ts @@ -0,0 +1,22 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.json" }, + }), + ], + test: { + reporters: ["default"], + include: ["test/**/*.spec.ts"], + server: { + deps: { + external: [ + /packages\/vitest-pool-workers\/dist/, + /packages\/wrangler\//, + ], + }, + }, + }, +}); diff --git a/fixtures/vitest-pool-workers-remote-bindings/worker-configuration.d.ts b/fixtures/vitest-pool-workers-remote-bindings/worker-configuration.d.ts new file mode 100644 index 0000000..a374ca3 --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/worker-configuration.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --no-include-runtime` (hash: ad45d03e3bc69c49113dbe042afdb1d6) +interface __BaseEnv_Env { + MY_WORKER: + | Fetcher /* my-staging-worker-test */ + | Fetcher /* my-worker-test */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface StagingEnv { + MY_WORKER: Fetcher /* my-staging-worker-test */; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} diff --git a/fixtures/vitest-pool-workers-remote-bindings/wrangler.json b/fixtures/vitest-pool-workers-remote-bindings/wrangler.json new file mode 100644 index 0000000..f21535c --- /dev/null +++ b/fixtures/vitest-pool-workers-remote-bindings/wrangler.json @@ -0,0 +1,23 @@ +{ + "name": "vitest-remote-bindings-fixture-test", + "main": "src/index.ts", + "compatibility_date": "2025-06-01", + "services": [ + { + "binding": "MY_WORKER", + "service": "my-worker-test", + "remote": true + } + ], + "env": { + "staging": { + "services": [ + { + "binding": "MY_WORKER", + "service": "my-staging-worker-test", + "remote": true + } + ] + } + } +} diff --git a/fixtures/wasm-app/README.md b/fixtures/wasm-app/README.md new file mode 100644 index 0000000..72907e3 --- /dev/null +++ b/fixtures/wasm-app/README.md @@ -0,0 +1,17 @@ +## wasm-app + +There are 3 workers in this package. They were created with a [`workers-rs`](https://github.com/cloudflare/workers-rs) project, running a build, removing unneeded artifacts, and copying the output folders. + +The wasm file generated, `index_bg.wasm` is copied into `./worker`, and shared by the 3 workers. + +- `./worker/module` contains a "modules" format worker and imports the wasm module as a regular ES module. +- `./worker/service-worker` contains a "service-worker" format worker and uses `wrangler.toml` to bind the wasm module as a global `MYWASM`. +- `./worker/service-worker-module` contains a "service-worker" format worker and imports the wasm module as a regular ES module. + +They're otherwise identical. + +You can run the module worker with `npx wrangler dev worker/module/index.js` (or from the `wrangler` package directory with `npm start -- dev ../../examples/wasm-app/worker/module/index.js`). + +You can run the service-worker worker with `npx wrangler dev worker/service-worker/index.js --config worker/service-worker/wrangler.jsonc` (or from the `wrangler` package directory with `npm start -- dev ../../examples/wasm-app/worker/service-worker/index.js --config ../../examples/wasm-app/worker/service-worker/wrangler.jsonc`). + +You can run the service-worker-module worker with `npx wrangler dev worker/service-worker-module/index.js` (or from the `wrangler` package directory with `npm start -- dev ../../examples/wasm-app/worker/service-worker-module/index.js`). diff --git a/fixtures/wasm-app/package.json b/fixtures/wasm-app/package.json new file mode 100644 index 0000000..568fdab --- /dev/null +++ b/fixtures/wasm-app/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixture/wasm-basic", + "private": true +} diff --git a/fixtures/wasm-app/worker/index_bg.wasm b/fixtures/wasm-app/worker/index_bg.wasm new file mode 100644 index 0000000..9ff1b9d Binary files /dev/null and b/fixtures/wasm-app/worker/index_bg.wasm differ diff --git a/fixtures/wasm-app/worker/module/export_wasm.js b/fixtures/wasm-app/worker/module/export_wasm.js new file mode 100644 index 0000000..5469c2c --- /dev/null +++ b/fixtures/wasm-app/worker/module/export_wasm.js @@ -0,0 +1,10 @@ +import _wasm from "../index_bg.wasm"; +import * as index_bg from "./index_bg.js"; + +const _wasm_memory = new WebAssembly.Memory({ initial: 512 }); +let importsObject = { + env: { memory: _wasm_memory }, + "./index_bg.js": index_bg, +}; + +export default new WebAssembly.Instance(_wasm, importsObject).exports; diff --git a/fixtures/wasm-app/worker/module/index.js b/fixtures/wasm-app/worker/module/index.js new file mode 100644 index 0000000..4dc2441 --- /dev/null +++ b/fixtures/wasm-app/worker/module/index.js @@ -0,0 +1,3 @@ +import * as worker from "./index_bg.js"; + +export default { fetch: worker.fetch }; diff --git a/fixtures/wasm-app/worker/module/index_bg.js b/fixtures/wasm-app/worker/module/index_bg.js new file mode 100644 index 0000000..4305fb0 --- /dev/null +++ b/fixtures/wasm-app/worker/module/index_bg.js @@ -0,0 +1,683 @@ +import wasm from "./export_wasm.js"; + +const heap = new Array(32).fill(undefined); + +heap.push(undefined, null, true, false); + +function getObject(idx) { + return heap[idx]; +} + +let heap_next = heap.length; + +function dropObject(idx) { + if (idx < 36) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let WASM_VECTOR_LEN = 0; + +let cachegetUint8Memory0 = null; +function getUint8Memory0() { + if ( + cachegetUint8Memory0 === null || + cachegetUint8Memory0.buffer !== wasm.memory.buffer + ) { + cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachegetUint8Memory0; +} + +const lTextEncoder = + typeof TextEncoder === "undefined" + ? (0, module.require)("util").TextEncoder + : TextEncoder; + +let cachedTextEncoder = new lTextEncoder("utf-8"); + +const encodeString = + typeof cachedTextEncoder.encodeInto === "function" + ? function (arg, view) { + return cachedTextEncoder.encodeInto(arg, view); + } + : function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length, + }; + }; + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length); + getUint8Memory0() + .subarray(ptr, ptr + buf.length) + .set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len); + + const mem = getUint8Memory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7f) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, (len = offset + arg.length * 3)); + const view = getUint8Memory0().subarray(ptr + offset, ptr + len); + const ret = encodeString(arg, view); + + offset += ret.written; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +let cachegetInt32Memory0 = null; +function getInt32Memory0() { + if ( + cachegetInt32Memory0 === null || + cachegetInt32Memory0.buffer !== wasm.memory.buffer + ) { + cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachegetInt32Memory0; +} + +const lTextDecoder = + typeof TextDecoder === "undefined" + ? (0, module.require)("util").TextDecoder + : TextDecoder; + +let cachedTextDecoder = new lTextDecoder("utf-8", { + ignoreBOM: true, + fatal: true, +}); + +cachedTextDecoder.decode(); + +function getStringFromWasm0(ptr, len) { + return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == "number" || type == "boolean" || val == null) { + return `${val}`; + } + if (type == "string") { + return `"${val}"`; + } + if (type == "symbol") { + const description = val.description; + if (description == null) { + return "Symbol"; + } else { + return `Symbol(${description})`; + } + } + if (type == "function") { + const name = val.name; + if (typeof name == "string" && name.length > 0) { + return `Function(${name})`; + } else { + return "Function"; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = "["; + if (length > 0) { + debug += debugString(val[0]); + } + for (let i = 1; i < length; i++) { + debug += ", " + debugString(val[i]); + } + debug += "]"; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == "Object") { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return "Object(" + JSON.stringify(val) + ")"; + } catch (_) { + return "Object"; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_2.get(state.dtor)(a, state.b); + } else { + state.a = a; + } + } + }; + real.original = state; + + return real; +} +function __wbg_adapter_22(arg0, arg1, arg2) { + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h29cf9a4fd4f08c73( + arg0, + arg1, + addHeapObject(arg2) + ); +} + +/** + * @param {any} req + * @param {any} env + * @param {any} ctx + * @returns {Promise} + */ +export function fetch(req, env, ctx) { + var ret = wasm.fetch( + addHeapObject(req), + addHeapObject(env), + addHeapObject(ctx) + ); + return takeObject(ret); +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_exn_store(addHeapObject(e)); + } +} +function __wbg_adapter_86(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures__invoke2_mut__h70365e5614c937ba( + arg0, + arg1, + addHeapObject(arg2), + addHeapObject(arg3) + ); +} + +/** + * Configuration options for Cloudflare's image optimization feature: + * + */ +export const PolishConfig = Object.freeze({ + Off: 0, + 0: "Off", + Lossy: 1, + 1: "Lossy", + Lossless: 2, + 2: "Lossless", +}); +/** + */ +export const RequestRedirect = Object.freeze({ + Error: 0, + 0: "Error", + Follow: 1, + 1: "Follow", + Manual: 2, + 2: "Manual", +}); +/** + * Configuration options for Cloudflare's minification features: + * + */ +export class MinifyConfig { + __destroy_into_raw() { + const ptr = this.ptr; + this.ptr = 0; + + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_minifyconfig_free(ptr); + } + /** + */ + get js() { + var ret = wasm.__wbg_get_minifyconfig_js(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set js(arg0) { + wasm.__wbg_set_minifyconfig_js(this.ptr, arg0); + } + /** + */ + get html() { + var ret = wasm.__wbg_get_minifyconfig_html(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set html(arg0) { + wasm.__wbg_set_minifyconfig_html(this.ptr, arg0); + } + /** + */ + get css() { + var ret = wasm.__wbg_get_minifyconfig_css(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set css(arg0) { + wasm.__wbg_set_minifyconfig_css(this.ptr, arg0); + } +} + +export function __wbindgen_object_drop_ref(arg0) { + takeObject(arg0); +} + +export function __wbindgen_string_get(arg0, arg1) { + const obj = getObject(arg1); + var ret = typeof obj === "string" ? obj : undefined; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_new_59cb74e423758ede() { + var ret = new Error(); + return addHeapObject(ret); +} + +export function __wbg_stack_558ba5917b466edd(arg0, arg1) { + var ret = getObject(arg1).stack; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_error_4bb6c2a97407129a(arg0, arg1) { + try { + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(arg0, arg1); + } +} + +export function __wbindgen_string_new(arg0, arg1) { + var ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); +} + +export function __wbindgen_is_undefined(arg0) { + var ret = getObject(arg0) === undefined; + return ret; +} + +export function __wbindgen_number_new(arg0) { + var ret = arg0; + return addHeapObject(ret); +} + +export function __wbindgen_object_clone_ref(arg0) { + var ret = getObject(arg0); + return addHeapObject(ret); +} + +export function __wbindgen_cb_drop(arg0) { + const obj = takeObject(arg0).original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + var ret = false; + return ret; +} + +export function __wbg_body_b67afdc865ca6d95(arg0) { + var ret = getObject(arg0).body; + return isLikeNone(ret) ? 0 : addHeapObject(ret); +} + +export function __wbg_newwithoptu8arrayandinit_2358601704784951() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstrandinit_cd8a4402e68873df() { + return handleError(function (arg0, arg1, arg2) { + var ret = new Response( + arg0 === 0 ? undefined : getStringFromWasm0(arg0, arg1), + getObject(arg2) + ); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstreamandinit_2cdcade777fddab8() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_latitude_6d0dc7510853aaea(arg0, arg1) { + var ret = getObject(arg1).latitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_longitude_b566ab6d05581b27(arg0, arg1) { + var ret = getObject(arg1).longitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_region_5b42be38a5fb9fee(arg0, arg1) { + var ret = getObject(arg1).region; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_method_8ec82ee079ce2702(arg0, arg1) { + var ret = getObject(arg1).method; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_url_9b689d511a7995b5(arg0, arg1) { + var ret = getObject(arg1).url; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_headers_62f682054d74e541(arg0) { + var ret = getObject(arg0).headers; + return addHeapObject(ret); +} + +export function __wbg_formData_c5b7ee7b1f027402() { + return handleError(function (arg0) { + var ret = getObject(arg0).formData(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_cf_619e9c1d3e10de88(arg0) { + var ret = getObject(arg0).cf; + return addHeapObject(ret); +} + +export function __wbg_new_9e449026aa04d852() { + return handleError(function () { + var ret = new Headers(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_set_ecc7ab7b550ca8b7() { + return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).set( + getStringFromWasm0(arg1, arg2), + getStringFromWasm0(arg3, arg4) + ); + }, arguments); +} + +export function __wbg_log_9c5d40d7de6fd4f7(arg0, arg1) { + console.log(getStringFromWasm0(arg0, arg1)); +} + +export function __wbg_instanceof_File_05917b01e27498d9(arg0) { + var ret = getObject(arg0) instanceof File; + return ret; +} + +export function __wbg_get_4139ec5751043532(arg0, arg1, arg2) { + var ret = getObject(arg0).get(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); +} + +export function __wbg_get_4d0f21c2f823742e() { + return handleError(function (arg0, arg1) { + var ret = Reflect.get(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_new_0b83d3df67ecb33e() { + var ret = new Object(); + return addHeapObject(ret); +} + +export function __wbg_instanceof_Error_561efcb1265706d8(arg0) { + var ret = getObject(arg0) instanceof Error; + return ret; +} + +export function __wbg_toString_0ef1ea57b966aed4(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_call_346669c262382ad7() { + return handleError(function (arg0, arg1, arg2) { + var ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_name_9a3ff1e21a0e3304(arg0) { + var ret = getObject(arg0).name; + return addHeapObject(ret); +} + +export function __wbg_new0_fd3a3a290b25cdac() { + var ret = new Date(); + return addHeapObject(ret); +} + +export function __wbg_toString_646e437de608a0a1(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_constructor_9fe544cc0957fdd0(arg0) { + var ret = getObject(arg0).constructor; + return addHeapObject(ret); +} + +export function __wbg_new_b1d61b5687f5e73a(arg0, arg1) { + try { + var state0 = { a: arg0, b: arg1 }; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_86(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + var ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = state0.b = 0; + } +} + +export function __wbg_resolve_d23068002f584f22(arg0) { + var ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); +} + +export function __wbg_then_2fcac196782070cc(arg0, arg1) { + var ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); +} + +export function __wbg_then_8c2d62e8ae5978f7(arg0, arg1, arg2) { + var ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); +} + +export function __wbg_buffer_397eaa4d72ee94dd(arg0) { + var ret = getObject(arg0).buffer; + return addHeapObject(ret); +} + +export function __wbg_newwithbyteoffsetandlength_4b9b8c4e3f5adbff( + arg0, + arg1, + arg2 +) { + var ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_969ad0a60e51d320(arg0, arg1, arg2) { + getObject(arg0).set(getObject(arg1), arg2 >>> 0); +} + +export function __wbg_length_1eb8fc608a0d4cdb(arg0) { + var ret = getObject(arg0).length; + return ret; +} + +export function __wbg_newwithlength_929232475839a482(arg0) { + var ret = new Uint8Array(arg0 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_82a4e8a85e31ac42() { + return handleError(function (arg0, arg1, arg2) { + var ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); + return ret; + }, arguments); +} + +export function __wbindgen_debug_string(arg0, arg1) { + var ret = debugString(getObject(arg1)); + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbindgen_throw(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); +} + +export function __wbindgen_memory() { + var ret = wasm.memory; + return addHeapObject(ret); +} + +export function __wbindgen_closure_wrapper515(arg0, arg1, arg2) { + var ret = makeMutClosure(arg0, arg1, 100, __wbg_adapter_22); + return addHeapObject(ret); +} diff --git a/fixtures/wasm-app/worker/service-worker-module/export_wasm.js b/fixtures/wasm-app/worker/service-worker-module/export_wasm.js new file mode 100644 index 0000000..5469c2c --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker-module/export_wasm.js @@ -0,0 +1,10 @@ +import _wasm from "../index_bg.wasm"; +import * as index_bg from "./index_bg.js"; + +const _wasm_memory = new WebAssembly.Memory({ initial: 512 }); +let importsObject = { + env: { memory: _wasm_memory }, + "./index_bg.js": index_bg, +}; + +export default new WebAssembly.Instance(_wasm, importsObject).exports; diff --git a/fixtures/wasm-app/worker/service-worker-module/index.js b/fixtures/wasm-app/worker/service-worker-module/index.js new file mode 100644 index 0000000..7b7e293 --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker-module/index.js @@ -0,0 +1,5 @@ +import * as worker from "./index_bg.js"; + +addEventListener("fetch", (event) => { + event.respondWith(worker.fetch(event.request)); +}); diff --git a/fixtures/wasm-app/worker/service-worker-module/index_bg.js b/fixtures/wasm-app/worker/service-worker-module/index_bg.js new file mode 100644 index 0000000..4305fb0 --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker-module/index_bg.js @@ -0,0 +1,683 @@ +import wasm from "./export_wasm.js"; + +const heap = new Array(32).fill(undefined); + +heap.push(undefined, null, true, false); + +function getObject(idx) { + return heap[idx]; +} + +let heap_next = heap.length; + +function dropObject(idx) { + if (idx < 36) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let WASM_VECTOR_LEN = 0; + +let cachegetUint8Memory0 = null; +function getUint8Memory0() { + if ( + cachegetUint8Memory0 === null || + cachegetUint8Memory0.buffer !== wasm.memory.buffer + ) { + cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachegetUint8Memory0; +} + +const lTextEncoder = + typeof TextEncoder === "undefined" + ? (0, module.require)("util").TextEncoder + : TextEncoder; + +let cachedTextEncoder = new lTextEncoder("utf-8"); + +const encodeString = + typeof cachedTextEncoder.encodeInto === "function" + ? function (arg, view) { + return cachedTextEncoder.encodeInto(arg, view); + } + : function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length, + }; + }; + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length); + getUint8Memory0() + .subarray(ptr, ptr + buf.length) + .set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len); + + const mem = getUint8Memory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7f) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, (len = offset + arg.length * 3)); + const view = getUint8Memory0().subarray(ptr + offset, ptr + len); + const ret = encodeString(arg, view); + + offset += ret.written; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +let cachegetInt32Memory0 = null; +function getInt32Memory0() { + if ( + cachegetInt32Memory0 === null || + cachegetInt32Memory0.buffer !== wasm.memory.buffer + ) { + cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachegetInt32Memory0; +} + +const lTextDecoder = + typeof TextDecoder === "undefined" + ? (0, module.require)("util").TextDecoder + : TextDecoder; + +let cachedTextDecoder = new lTextDecoder("utf-8", { + ignoreBOM: true, + fatal: true, +}); + +cachedTextDecoder.decode(); + +function getStringFromWasm0(ptr, len) { + return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == "number" || type == "boolean" || val == null) { + return `${val}`; + } + if (type == "string") { + return `"${val}"`; + } + if (type == "symbol") { + const description = val.description; + if (description == null) { + return "Symbol"; + } else { + return `Symbol(${description})`; + } + } + if (type == "function") { + const name = val.name; + if (typeof name == "string" && name.length > 0) { + return `Function(${name})`; + } else { + return "Function"; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = "["; + if (length > 0) { + debug += debugString(val[0]); + } + for (let i = 1; i < length; i++) { + debug += ", " + debugString(val[i]); + } + debug += "]"; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == "Object") { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return "Object(" + JSON.stringify(val) + ")"; + } catch (_) { + return "Object"; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_2.get(state.dtor)(a, state.b); + } else { + state.a = a; + } + } + }; + real.original = state; + + return real; +} +function __wbg_adapter_22(arg0, arg1, arg2) { + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h29cf9a4fd4f08c73( + arg0, + arg1, + addHeapObject(arg2) + ); +} + +/** + * @param {any} req + * @param {any} env + * @param {any} ctx + * @returns {Promise} + */ +export function fetch(req, env, ctx) { + var ret = wasm.fetch( + addHeapObject(req), + addHeapObject(env), + addHeapObject(ctx) + ); + return takeObject(ret); +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_exn_store(addHeapObject(e)); + } +} +function __wbg_adapter_86(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures__invoke2_mut__h70365e5614c937ba( + arg0, + arg1, + addHeapObject(arg2), + addHeapObject(arg3) + ); +} + +/** + * Configuration options for Cloudflare's image optimization feature: + * + */ +export const PolishConfig = Object.freeze({ + Off: 0, + 0: "Off", + Lossy: 1, + 1: "Lossy", + Lossless: 2, + 2: "Lossless", +}); +/** + */ +export const RequestRedirect = Object.freeze({ + Error: 0, + 0: "Error", + Follow: 1, + 1: "Follow", + Manual: 2, + 2: "Manual", +}); +/** + * Configuration options for Cloudflare's minification features: + * + */ +export class MinifyConfig { + __destroy_into_raw() { + const ptr = this.ptr; + this.ptr = 0; + + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_minifyconfig_free(ptr); + } + /** + */ + get js() { + var ret = wasm.__wbg_get_minifyconfig_js(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set js(arg0) { + wasm.__wbg_set_minifyconfig_js(this.ptr, arg0); + } + /** + */ + get html() { + var ret = wasm.__wbg_get_minifyconfig_html(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set html(arg0) { + wasm.__wbg_set_minifyconfig_html(this.ptr, arg0); + } + /** + */ + get css() { + var ret = wasm.__wbg_get_minifyconfig_css(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set css(arg0) { + wasm.__wbg_set_minifyconfig_css(this.ptr, arg0); + } +} + +export function __wbindgen_object_drop_ref(arg0) { + takeObject(arg0); +} + +export function __wbindgen_string_get(arg0, arg1) { + const obj = getObject(arg1); + var ret = typeof obj === "string" ? obj : undefined; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_new_59cb74e423758ede() { + var ret = new Error(); + return addHeapObject(ret); +} + +export function __wbg_stack_558ba5917b466edd(arg0, arg1) { + var ret = getObject(arg1).stack; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_error_4bb6c2a97407129a(arg0, arg1) { + try { + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(arg0, arg1); + } +} + +export function __wbindgen_string_new(arg0, arg1) { + var ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); +} + +export function __wbindgen_is_undefined(arg0) { + var ret = getObject(arg0) === undefined; + return ret; +} + +export function __wbindgen_number_new(arg0) { + var ret = arg0; + return addHeapObject(ret); +} + +export function __wbindgen_object_clone_ref(arg0) { + var ret = getObject(arg0); + return addHeapObject(ret); +} + +export function __wbindgen_cb_drop(arg0) { + const obj = takeObject(arg0).original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + var ret = false; + return ret; +} + +export function __wbg_body_b67afdc865ca6d95(arg0) { + var ret = getObject(arg0).body; + return isLikeNone(ret) ? 0 : addHeapObject(ret); +} + +export function __wbg_newwithoptu8arrayandinit_2358601704784951() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstrandinit_cd8a4402e68873df() { + return handleError(function (arg0, arg1, arg2) { + var ret = new Response( + arg0 === 0 ? undefined : getStringFromWasm0(arg0, arg1), + getObject(arg2) + ); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstreamandinit_2cdcade777fddab8() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_latitude_6d0dc7510853aaea(arg0, arg1) { + var ret = getObject(arg1).latitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_longitude_b566ab6d05581b27(arg0, arg1) { + var ret = getObject(arg1).longitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_region_5b42be38a5fb9fee(arg0, arg1) { + var ret = getObject(arg1).region; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_method_8ec82ee079ce2702(arg0, arg1) { + var ret = getObject(arg1).method; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_url_9b689d511a7995b5(arg0, arg1) { + var ret = getObject(arg1).url; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_headers_62f682054d74e541(arg0) { + var ret = getObject(arg0).headers; + return addHeapObject(ret); +} + +export function __wbg_formData_c5b7ee7b1f027402() { + return handleError(function (arg0) { + var ret = getObject(arg0).formData(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_cf_619e9c1d3e10de88(arg0) { + var ret = getObject(arg0).cf; + return addHeapObject(ret); +} + +export function __wbg_new_9e449026aa04d852() { + return handleError(function () { + var ret = new Headers(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_set_ecc7ab7b550ca8b7() { + return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).set( + getStringFromWasm0(arg1, arg2), + getStringFromWasm0(arg3, arg4) + ); + }, arguments); +} + +export function __wbg_log_9c5d40d7de6fd4f7(arg0, arg1) { + console.log(getStringFromWasm0(arg0, arg1)); +} + +export function __wbg_instanceof_File_05917b01e27498d9(arg0) { + var ret = getObject(arg0) instanceof File; + return ret; +} + +export function __wbg_get_4139ec5751043532(arg0, arg1, arg2) { + var ret = getObject(arg0).get(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); +} + +export function __wbg_get_4d0f21c2f823742e() { + return handleError(function (arg0, arg1) { + var ret = Reflect.get(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_new_0b83d3df67ecb33e() { + var ret = new Object(); + return addHeapObject(ret); +} + +export function __wbg_instanceof_Error_561efcb1265706d8(arg0) { + var ret = getObject(arg0) instanceof Error; + return ret; +} + +export function __wbg_toString_0ef1ea57b966aed4(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_call_346669c262382ad7() { + return handleError(function (arg0, arg1, arg2) { + var ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_name_9a3ff1e21a0e3304(arg0) { + var ret = getObject(arg0).name; + return addHeapObject(ret); +} + +export function __wbg_new0_fd3a3a290b25cdac() { + var ret = new Date(); + return addHeapObject(ret); +} + +export function __wbg_toString_646e437de608a0a1(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_constructor_9fe544cc0957fdd0(arg0) { + var ret = getObject(arg0).constructor; + return addHeapObject(ret); +} + +export function __wbg_new_b1d61b5687f5e73a(arg0, arg1) { + try { + var state0 = { a: arg0, b: arg1 }; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_86(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + var ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = state0.b = 0; + } +} + +export function __wbg_resolve_d23068002f584f22(arg0) { + var ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); +} + +export function __wbg_then_2fcac196782070cc(arg0, arg1) { + var ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); +} + +export function __wbg_then_8c2d62e8ae5978f7(arg0, arg1, arg2) { + var ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); +} + +export function __wbg_buffer_397eaa4d72ee94dd(arg0) { + var ret = getObject(arg0).buffer; + return addHeapObject(ret); +} + +export function __wbg_newwithbyteoffsetandlength_4b9b8c4e3f5adbff( + arg0, + arg1, + arg2 +) { + var ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_969ad0a60e51d320(arg0, arg1, arg2) { + getObject(arg0).set(getObject(arg1), arg2 >>> 0); +} + +export function __wbg_length_1eb8fc608a0d4cdb(arg0) { + var ret = getObject(arg0).length; + return ret; +} + +export function __wbg_newwithlength_929232475839a482(arg0) { + var ret = new Uint8Array(arg0 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_82a4e8a85e31ac42() { + return handleError(function (arg0, arg1, arg2) { + var ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); + return ret; + }, arguments); +} + +export function __wbindgen_debug_string(arg0, arg1) { + var ret = debugString(getObject(arg1)); + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbindgen_throw(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); +} + +export function __wbindgen_memory() { + var ret = wasm.memory; + return addHeapObject(ret); +} + +export function __wbindgen_closure_wrapper515(arg0, arg1, arg2) { + var ret = makeMutClosure(arg0, arg1, 100, __wbg_adapter_22); + return addHeapObject(ret); +} diff --git a/fixtures/wasm-app/worker/service-worker/export_wasm.js b/fixtures/wasm-app/worker/service-worker/export_wasm.js new file mode 100644 index 0000000..bd9ed4a --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker/export_wasm.js @@ -0,0 +1,11 @@ +import * as index_bg from "./index_bg.js"; + +const _wasm = MYWASM; + +const _wasm_memory = new WebAssembly.Memory({ initial: 512 }); +let importsObject = { + env: { memory: _wasm_memory }, + "./index_bg.js": index_bg, +}; + +export default new WebAssembly.Instance(_wasm, importsObject).exports; diff --git a/fixtures/wasm-app/worker/service-worker/index.js b/fixtures/wasm-app/worker/service-worker/index.js new file mode 100644 index 0000000..7b7e293 --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker/index.js @@ -0,0 +1,5 @@ +import * as worker from "./index_bg.js"; + +addEventListener("fetch", (event) => { + event.respondWith(worker.fetch(event.request)); +}); diff --git a/fixtures/wasm-app/worker/service-worker/index_bg.js b/fixtures/wasm-app/worker/service-worker/index_bg.js new file mode 100644 index 0000000..4305fb0 --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker/index_bg.js @@ -0,0 +1,683 @@ +import wasm from "./export_wasm.js"; + +const heap = new Array(32).fill(undefined); + +heap.push(undefined, null, true, false); + +function getObject(idx) { + return heap[idx]; +} + +let heap_next = heap.length; + +function dropObject(idx) { + if (idx < 36) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let WASM_VECTOR_LEN = 0; + +let cachegetUint8Memory0 = null; +function getUint8Memory0() { + if ( + cachegetUint8Memory0 === null || + cachegetUint8Memory0.buffer !== wasm.memory.buffer + ) { + cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachegetUint8Memory0; +} + +const lTextEncoder = + typeof TextEncoder === "undefined" + ? (0, module.require)("util").TextEncoder + : TextEncoder; + +let cachedTextEncoder = new lTextEncoder("utf-8"); + +const encodeString = + typeof cachedTextEncoder.encodeInto === "function" + ? function (arg, view) { + return cachedTextEncoder.encodeInto(arg, view); + } + : function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length, + }; + }; + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length); + getUint8Memory0() + .subarray(ptr, ptr + buf.length) + .set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len); + + const mem = getUint8Memory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7f) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, (len = offset + arg.length * 3)); + const view = getUint8Memory0().subarray(ptr + offset, ptr + len); + const ret = encodeString(arg, view); + + offset += ret.written; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +let cachegetInt32Memory0 = null; +function getInt32Memory0() { + if ( + cachegetInt32Memory0 === null || + cachegetInt32Memory0.buffer !== wasm.memory.buffer + ) { + cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachegetInt32Memory0; +} + +const lTextDecoder = + typeof TextDecoder === "undefined" + ? (0, module.require)("util").TextDecoder + : TextDecoder; + +let cachedTextDecoder = new lTextDecoder("utf-8", { + ignoreBOM: true, + fatal: true, +}); + +cachedTextDecoder.decode(); + +function getStringFromWasm0(ptr, len) { + return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == "number" || type == "boolean" || val == null) { + return `${val}`; + } + if (type == "string") { + return `"${val}"`; + } + if (type == "symbol") { + const description = val.description; + if (description == null) { + return "Symbol"; + } else { + return `Symbol(${description})`; + } + } + if (type == "function") { + const name = val.name; + if (typeof name == "string" && name.length > 0) { + return `Function(${name})`; + } else { + return "Function"; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = "["; + if (length > 0) { + debug += debugString(val[0]); + } + for (let i = 1; i < length; i++) { + debug += ", " + debugString(val[i]); + } + debug += "]"; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == "Object") { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return "Object(" + JSON.stringify(val) + ")"; + } catch (_) { + return "Object"; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_2.get(state.dtor)(a, state.b); + } else { + state.a = a; + } + } + }; + real.original = state; + + return real; +} +function __wbg_adapter_22(arg0, arg1, arg2) { + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h29cf9a4fd4f08c73( + arg0, + arg1, + addHeapObject(arg2) + ); +} + +/** + * @param {any} req + * @param {any} env + * @param {any} ctx + * @returns {Promise} + */ +export function fetch(req, env, ctx) { + var ret = wasm.fetch( + addHeapObject(req), + addHeapObject(env), + addHeapObject(ctx) + ); + return takeObject(ret); +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_exn_store(addHeapObject(e)); + } +} +function __wbg_adapter_86(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures__invoke2_mut__h70365e5614c937ba( + arg0, + arg1, + addHeapObject(arg2), + addHeapObject(arg3) + ); +} + +/** + * Configuration options for Cloudflare's image optimization feature: + * + */ +export const PolishConfig = Object.freeze({ + Off: 0, + 0: "Off", + Lossy: 1, + 1: "Lossy", + Lossless: 2, + 2: "Lossless", +}); +/** + */ +export const RequestRedirect = Object.freeze({ + Error: 0, + 0: "Error", + Follow: 1, + 1: "Follow", + Manual: 2, + 2: "Manual", +}); +/** + * Configuration options for Cloudflare's minification features: + * + */ +export class MinifyConfig { + __destroy_into_raw() { + const ptr = this.ptr; + this.ptr = 0; + + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_minifyconfig_free(ptr); + } + /** + */ + get js() { + var ret = wasm.__wbg_get_minifyconfig_js(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set js(arg0) { + wasm.__wbg_set_minifyconfig_js(this.ptr, arg0); + } + /** + */ + get html() { + var ret = wasm.__wbg_get_minifyconfig_html(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set html(arg0) { + wasm.__wbg_set_minifyconfig_html(this.ptr, arg0); + } + /** + */ + get css() { + var ret = wasm.__wbg_get_minifyconfig_css(this.ptr); + return ret !== 0; + } + /** + * @param {boolean} arg0 + */ + set css(arg0) { + wasm.__wbg_set_minifyconfig_css(this.ptr, arg0); + } +} + +export function __wbindgen_object_drop_ref(arg0) { + takeObject(arg0); +} + +export function __wbindgen_string_get(arg0, arg1) { + const obj = getObject(arg1); + var ret = typeof obj === "string" ? obj : undefined; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_new_59cb74e423758ede() { + var ret = new Error(); + return addHeapObject(ret); +} + +export function __wbg_stack_558ba5917b466edd(arg0, arg1) { + var ret = getObject(arg1).stack; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_error_4bb6c2a97407129a(arg0, arg1) { + try { + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(arg0, arg1); + } +} + +export function __wbindgen_string_new(arg0, arg1) { + var ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); +} + +export function __wbindgen_is_undefined(arg0) { + var ret = getObject(arg0) === undefined; + return ret; +} + +export function __wbindgen_number_new(arg0) { + var ret = arg0; + return addHeapObject(ret); +} + +export function __wbindgen_object_clone_ref(arg0) { + var ret = getObject(arg0); + return addHeapObject(ret); +} + +export function __wbindgen_cb_drop(arg0) { + const obj = takeObject(arg0).original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + var ret = false; + return ret; +} + +export function __wbg_body_b67afdc865ca6d95(arg0) { + var ret = getObject(arg0).body; + return isLikeNone(ret) ? 0 : addHeapObject(ret); +} + +export function __wbg_newwithoptu8arrayandinit_2358601704784951() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstrandinit_cd8a4402e68873df() { + return handleError(function (arg0, arg1, arg2) { + var ret = new Response( + arg0 === 0 ? undefined : getStringFromWasm0(arg0, arg1), + getObject(arg2) + ); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_newwithoptstreamandinit_2cdcade777fddab8() { + return handleError(function (arg0, arg1) { + var ret = new Response(takeObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_latitude_6d0dc7510853aaea(arg0, arg1) { + var ret = getObject(arg1).latitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_longitude_b566ab6d05581b27(arg0, arg1) { + var ret = getObject(arg1).longitude; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_region_5b42be38a5fb9fee(arg0, arg1) { + var ret = getObject(arg1).region; + var ptr0 = isLikeNone(ret) + ? 0 + : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_method_8ec82ee079ce2702(arg0, arg1) { + var ret = getObject(arg1).method; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_url_9b689d511a7995b5(arg0, arg1) { + var ret = getObject(arg1).url; + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbg_headers_62f682054d74e541(arg0) { + var ret = getObject(arg0).headers; + return addHeapObject(ret); +} + +export function __wbg_formData_c5b7ee7b1f027402() { + return handleError(function (arg0) { + var ret = getObject(arg0).formData(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_cf_619e9c1d3e10de88(arg0) { + var ret = getObject(arg0).cf; + return addHeapObject(ret); +} + +export function __wbg_new_9e449026aa04d852() { + return handleError(function () { + var ret = new Headers(); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_set_ecc7ab7b550ca8b7() { + return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).set( + getStringFromWasm0(arg1, arg2), + getStringFromWasm0(arg3, arg4) + ); + }, arguments); +} + +export function __wbg_log_9c5d40d7de6fd4f7(arg0, arg1) { + console.log(getStringFromWasm0(arg0, arg1)); +} + +export function __wbg_instanceof_File_05917b01e27498d9(arg0) { + var ret = getObject(arg0) instanceof File; + return ret; +} + +export function __wbg_get_4139ec5751043532(arg0, arg1, arg2) { + var ret = getObject(arg0).get(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); +} + +export function __wbg_get_4d0f21c2f823742e() { + return handleError(function (arg0, arg1) { + var ret = Reflect.get(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_new_0b83d3df67ecb33e() { + var ret = new Object(); + return addHeapObject(ret); +} + +export function __wbg_instanceof_Error_561efcb1265706d8(arg0) { + var ret = getObject(arg0) instanceof Error; + return ret; +} + +export function __wbg_toString_0ef1ea57b966aed4(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_call_346669c262382ad7() { + return handleError(function (arg0, arg1, arg2) { + var ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); +} + +export function __wbg_name_9a3ff1e21a0e3304(arg0) { + var ret = getObject(arg0).name; + return addHeapObject(ret); +} + +export function __wbg_new0_fd3a3a290b25cdac() { + var ret = new Date(); + return addHeapObject(ret); +} + +export function __wbg_toString_646e437de608a0a1(arg0) { + var ret = getObject(arg0).toString(); + return addHeapObject(ret); +} + +export function __wbg_constructor_9fe544cc0957fdd0(arg0) { + var ret = getObject(arg0).constructor; + return addHeapObject(ret); +} + +export function __wbg_new_b1d61b5687f5e73a(arg0, arg1) { + try { + var state0 = { a: arg0, b: arg1 }; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_86(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + var ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = state0.b = 0; + } +} + +export function __wbg_resolve_d23068002f584f22(arg0) { + var ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); +} + +export function __wbg_then_2fcac196782070cc(arg0, arg1) { + var ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); +} + +export function __wbg_then_8c2d62e8ae5978f7(arg0, arg1, arg2) { + var ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); +} + +export function __wbg_buffer_397eaa4d72ee94dd(arg0) { + var ret = getObject(arg0).buffer; + return addHeapObject(ret); +} + +export function __wbg_newwithbyteoffsetandlength_4b9b8c4e3f5adbff( + arg0, + arg1, + arg2 +) { + var ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_969ad0a60e51d320(arg0, arg1, arg2) { + getObject(arg0).set(getObject(arg1), arg2 >>> 0); +} + +export function __wbg_length_1eb8fc608a0d4cdb(arg0) { + var ret = getObject(arg0).length; + return ret; +} + +export function __wbg_newwithlength_929232475839a482(arg0) { + var ret = new Uint8Array(arg0 >>> 0); + return addHeapObject(ret); +} + +export function __wbg_set_82a4e8a85e31ac42() { + return handleError(function (arg0, arg1, arg2) { + var ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); + return ret; + }, arguments); +} + +export function __wbindgen_debug_string(arg0, arg1) { + var ret = debugString(getObject(arg1)); + var ptr0 = passStringToWasm0( + ret, + wasm.__wbindgen_malloc, + wasm.__wbindgen_realloc + ); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; +} + +export function __wbindgen_throw(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); +} + +export function __wbindgen_memory() { + var ret = wasm.memory; + return addHeapObject(ret); +} + +export function __wbindgen_closure_wrapper515(arg0, arg1, arg2) { + var ret = makeMutClosure(arg0, arg1, 100, __wbg_adapter_22); + return addHeapObject(ret); +} diff --git a/fixtures/wasm-app/worker/service-worker/wrangler.jsonc b/fixtures/wasm-app/worker/service-worker/wrangler.jsonc new file mode 100644 index 0000000..4119cd3 --- /dev/null +++ b/fixtures/wasm-app/worker/service-worker/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "wasm_modules": { + "MYWASM": "../index_bg.wasm", + }, +} diff --git a/fixtures/wildcard-modules/package.json b/fixtures/wildcard-modules/package.json new file mode 100644 index 0000000..4528977 --- /dev/null +++ b/fixtures/wildcard-modules/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/wildcard-modules", + "private": true, + "scripts": { + "build": "wrangler deploy --dry-run --outdir=dist", + "check:type": "tsc", + "deploy": "wrangler deploy", + "start": "wrangler dev --x-dev-env", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./test/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/shared": "workspace:*", + "undici": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/wildcard-modules/src/common.cjs b/fixtures/wildcard-modules/src/common.cjs new file mode 100644 index 0000000..19658c1 --- /dev/null +++ b/fixtures/wildcard-modules/src/common.cjs @@ -0,0 +1 @@ +module.exports = "common"; diff --git a/fixtures/wildcard-modules/src/dep.ts b/fixtures/wildcard-modules/src/dep.ts new file mode 100644 index 0000000..cc0be2a --- /dev/null +++ b/fixtures/wildcard-modules/src/dep.ts @@ -0,0 +1 @@ +export default "bundled"; diff --git a/fixtures/wildcard-modules/src/dynamic.js b/fixtures/wildcard-modules/src/dynamic.js new file mode 100644 index 0000000..6c40343 --- /dev/null +++ b/fixtures/wildcard-modules/src/dynamic.js @@ -0,0 +1 @@ +export default "dynamic"; diff --git a/fixtures/wildcard-modules/src/index.ts b/fixtures/wildcard-modules/src/index.ts new file mode 100644 index 0000000..a831998 --- /dev/null +++ b/fixtures/wildcard-modules/src/index.ts @@ -0,0 +1,32 @@ +import common from "./common.cjs"; +import dep from "./dep"; +import text from "./text.txt"; + +export default { + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/dep") { + return new Response(dep); + } + if (url.pathname === "/text") { + return new Response(text); + } + if (url.pathname === "/common") { + return new Response(common); + } + if (url.pathname === "/dynamic") { + return new Response((await import("./dynamic.js")).default); + } + if (url.pathname.startsWith("/lang/")) { + // Build the path dynamically to ensure esbuild does a wildcard import + return new Response( + ( + await import( + "./lang/" + url.pathname.substring("/lang/".length) + ".js" + ) + ).default.hello + ); + } + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/fixtures/wildcard-modules/src/lang/en.js b/fixtures/wildcard-modules/src/lang/en.js new file mode 100644 index 0000000..969f5b9 --- /dev/null +++ b/fixtures/wildcard-modules/src/lang/en.js @@ -0,0 +1 @@ +export default { hello: "hello" }; diff --git a/fixtures/wildcard-modules/src/lang/fr.js b/fixtures/wildcard-modules/src/lang/fr.js new file mode 100644 index 0000000..67e5320 --- /dev/null +++ b/fixtures/wildcard-modules/src/lang/fr.js @@ -0,0 +1 @@ +export default { hello: "bonjour" }; diff --git a/fixtures/wildcard-modules/src/text.d.ts b/fixtures/wildcard-modules/src/text.d.ts new file mode 100644 index 0000000..4695e49 --- /dev/null +++ b/fixtures/wildcard-modules/src/text.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const value: string; + export default value; +} diff --git a/fixtures/wildcard-modules/src/text.txt b/fixtures/wildcard-modules/src/text.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/fixtures/wildcard-modules/src/text.txt @@ -0,0 +1 @@ +test diff --git a/fixtures/wildcard-modules/test/index.test.ts b/fixtures/wildcard-modules/test/index.test.ts new file mode 100644 index 0000000..a5e2d12 --- /dev/null +++ b/fixtures/wildcard-modules/test/index.test.ts @@ -0,0 +1,207 @@ +import childProcess from "node:child_process"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { removeDir } from "@fixture/shared/src/fs-helpers"; +import { fetch } from "undici"; +import { afterAll, assert, beforeAll, describe, test } from "vitest"; +import { + runWranglerDev, + wranglerEntryPath, +} from "../../shared/src/run-wrangler-long-lived"; + +async function getTmpDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "wrangler-modules-")); +} + +type WranglerDev = Awaited>; +function get(worker: WranglerDev, pathname: string) { + const url = `http://${worker.ip}:${worker.port}${pathname}`; + // Disable Miniflare's pretty error page, so we can parse errors as JSON + return fetch(url, { headers: { "MF-Disable-Pretty-Error": "true" } }); +} + +async function retry(closure: () => Promise, max = 30): Promise { + for (let attempt = 1; attempt <= max; attempt++) { + try { + return await closure(); + } catch (e) { + if (attempt === max) throw e; + } + await setTimeout(1_000); + } + assert.fail("Unreachable"); +} + +describe("wildcard imports: dev", () => { + let tmpDir: string; + let worker: WranglerDev; + + beforeAll(async () => { + // Copy over files to a temporary directory as we'll be modifying them + tmpDir = await getTmpDir(); + await fs.cp( + path.resolve(__dirname, "..", "src"), + path.join(tmpDir, "src"), + { recursive: true } + ); + await fs.cp( + path.resolve(__dirname, "..", "wrangler.jsonc"), + path.join(tmpDir, "wrangler.jsonc") + ); + + worker = await runWranglerDev(tmpDir, ["--port=0", "--inspector-port=0"]); + }); + afterAll(async () => { + await worker.stop(); + removeDir(tmpDir, { fireAndForget: true }); + }); + + test("supports bundled modules", async ({ expect }) => { + const res = await get(worker, "/dep"); + expect(await res.text()).toBe("bundled"); + }); + test("supports text modules", async ({ expect }) => { + const res = await get(worker, "/text"); + expect(await res.text()).toBe("test\n"); + }); + test("supports dynamic imports", async ({ expect }) => { + const res = await get(worker, "/dynamic"); + expect(await res.text()).toBe("dynamic"); + }); + test("supports commonjs lazy imports", async ({ expect }) => { + const res = await get(worker, "/common"); + expect(await res.text()).toBe("common"); + }); + test("supports variable dynamic imports", async ({ expect }) => { + const res = await get(worker, "/lang/en"); + expect(await res.text()).toBe("hello"); + }); + + test("watches wildcard modules", async ({ expect }) => { + const srcDir = path.join(tmpDir, "src"); + + // Update dynamically imported file + await fs.writeFile( + path.join(srcDir, "dynamic.js"), + 'export default "new dynamic";' + ); + await retry(async () => { + const res = await get(worker, "/dynamic"); + assert.strictEqual(await res.text(), "new dynamic"); + }); + + // Delete dynamically imported file + await fs.rm(path.join(srcDir, "lang", "en.js")); + const res = await retry(async () => { + const res = await get(worker, "/lang/en"); + assert.strictEqual(res.status, 500); + return res; + }); + const error = (await res.json()) as { message?: string }; + expect(error.message).toBe("Module not found in bundle: ./lang/en.js"); + + // Create new dynamically imported file in new directory + await fs.mkdir(path.join(srcDir, "lang", "en")); + await fs.writeFile( + path.join(srcDir, "lang", "en", "us.js"), + 'export default { hello: "hey" };' + ); + await retry(async () => { + const res = await get(worker, "/lang/en/us"); + assert.strictEqual(await res.text(), "hey"); + }); + + // Update newly created file + await fs.writeFile( + path.join(srcDir, "lang", "en", "us.js"), + 'export default { hello: "bye" };' + ); + await retry(async () => { + const res = await get(worker, "/lang/en/us"); + assert.strictEqual(await res.text(), "bye"); + }); + }); +}); + +function build(cwd: string, outDir: string) { + return childProcess.spawnSync( + process.execPath, + [wranglerEntryPath, "deploy", "--dry-run", `--outdir=${outDir}`], + { cwd } + ); +} + +describe("wildcard imports: deploy", () => { + let tmpDir: string; + beforeAll(async () => { + tmpDir = await getTmpDir(); + }); + afterAll(() => { + removeDir(tmpDir, { fireAndForget: true }); + }); + + test("bundles wildcard modules", async ({ expect }) => { + const outDir = path.join(tmpDir, "out"); + const result = build(path.resolve(__dirname, ".."), outDir); + expect(result.status).toBe(0); + + // Check additional modules marked external, but other dependencies bundled + const bundledEntryPath = path.join(outDir, "index.js"); + const bundledEntry = await fs.readFile(bundledEntryPath, "utf8"); + const imports = [ + `import text from "./4e1243bd22c66e76c2ba9eddc1f91394e57f9f83-text.txt";`, + `// src/common.cjs`, + `// src/lang/en.js`, + `// src/lang/fr.js`, + `// src/dynamic.js`, + `// import("./lang/**/*.js") in src/index.ts`, + ]; + for (const importStatement of imports) { + expect(bundledEntry).toContain(importStatement); + } + + // Check additional modules included in output + expect( + existsSync( + path.join(outDir, "4e1243bd22c66e76c2ba9eddc1f91394e57f9f83-text.txt") + ) + ).toBe(true); + }); + + test("fails with service worker entrypoint", async ({ expect }) => { + const serviceWorkerDir = path.join(tmpDir, "service-worker"); + await fs.mkdir(serviceWorkerDir, { recursive: true }); + await fs.writeFile( + path.join(serviceWorkerDir, "index.js"), + ` + async function handleRequest(e) { + const url = new URL(e.request.url); + if (url.pathname === "/") { + return new Response("Hello World"); + } else if(url.pathname.startsWith("/lang/")) { + const language = url.pathname.substring("/lang/".length); + return new Response((await import(\`./lang/\${language}.js\`)).default.hello); + } + return new Response("Not Found", { status: 404 }); + } + + addEventListener('fetch', (e) => e.respondWith(handleRequest(e)))` + ); + await fs.writeFile( + path.join(serviceWorkerDir, "wrangler.toml"), + [ + 'name="service-worker-test"', + 'main = "index.js"', + 'compatibility_date = "2024-09-09"', + ].join("\n") + ); + + // Try build, and check fails + const serviceWorkerOutDir = path.join(tmpDir, "service-worker-out"); + const result = build(serviceWorkerDir, serviceWorkerOutDir); + expect(result.status).toBe(1); + }); +}); diff --git a/fixtures/wildcard-modules/test/tsconfig.json b/fixtures/wildcard-modules/test/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/wildcard-modules/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/wildcard-modules/tsconfig.json b/fixtures/wildcard-modules/tsconfig.json new file mode 100644 index 0000000..873892f --- /dev/null +++ b/fixtures/wildcard-modules/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["esnext"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "types": ["@cloudflare/workers-types/experimental"], + "allowJs": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/fixtures/wildcard-modules/turbo.json b/fixtures/wildcard-modules/turbo.json new file mode 100644 index 0000000..6556dcf --- /dev/null +++ b/fixtures/wildcard-modules/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/wildcard-modules/vitest.config.mts b/fixtures/wildcard-modules/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/wildcard-modules/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/wildcard-modules/wrangler.jsonc b/fixtures/wildcard-modules/wrangler.jsonc new file mode 100644 index 0000000..4c9ab9d --- /dev/null +++ b/fixtures/wildcard-modules/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "wildcard-modules", + "main": "src/index.ts", + "compatibility_date": "2024-09-09", +} diff --git a/fixtures/worker-app/.env b/fixtures/worker-app/.env new file mode 100644 index 0000000..1566bb1 --- /dev/null +++ b/fixtures/worker-app/.env @@ -0,0 +1 @@ +FOO=bar \ No newline at end of file diff --git a/fixtures/worker-app/.gitignore b/fixtures/worker-app/.gitignore new file mode 100644 index 0000000..b67a5dd --- /dev/null +++ b/fixtures/worker-app/.gitignore @@ -0,0 +1,2 @@ +dist +!.env \ No newline at end of file diff --git a/fixtures/worker-app/package.json b/fixtures/worker-app/package.json new file mode 100644 index 0000000..0983e13 --- /dev/null +++ b/fixtures/worker-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fixture/worker-basic", + "private": true, + "description": "", + "license": "ISC", + "author": "", + "main": "src/index.js", + "scripts": { + "check:type": "tsc", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "dependencies": { + "@fixture/isomorphic-random": "workspace:^", + "cookie": "^0.6.0" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/worker-app/public/404.html b/fixtures/worker-app/public/404.html new file mode 100644 index 0000000..5ac9921 --- /dev/null +++ b/fixtures/worker-app/public/404.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+

404 Not Found

+

Oh dang! We couldn't find that page.

+ a sad crab is unable to unable to lasso a paper airplane. 404 not found. +
+ + diff --git a/fixtures/worker-app/public/favicon.ico b/fixtures/worker-app/public/favicon.ico new file mode 100644 index 0000000..cc6c23b Binary files /dev/null and b/fixtures/worker-app/public/favicon.ico differ diff --git a/fixtures/worker-app/public/img/200-wrangler-ferris.gif b/fixtures/worker-app/public/img/200-wrangler-ferris.gif new file mode 100644 index 0000000..8853751 Binary files /dev/null and b/fixtures/worker-app/public/img/200-wrangler-ferris.gif differ diff --git a/fixtures/worker-app/public/img/404-wrangler-ferris.gif b/fixtures/worker-app/public/img/404-wrangler-ferris.gif new file mode 100644 index 0000000..0ac1479 Binary files /dev/null and b/fixtures/worker-app/public/img/404-wrangler-ferris.gif differ diff --git a/fixtures/worker-app/public/index.html b/fixtures/worker-app/public/index.html new file mode 100644 index 0000000..102985f --- /dev/null +++ b/fixtures/worker-app/public/index.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+

200 Success

+

Hello World! Welcome to your Workers Site.

+ a happy crab is wearing a cowboy hat and holding a lasso. 200 success. +
+ + diff --git a/fixtures/worker-app/src/dep.js b/fixtures/worker-app/src/dep.js new file mode 100644 index 0000000..2256914 --- /dev/null +++ b/fixtures/worker-app/src/dep.js @@ -0,0 +1,3 @@ +export function now() { + return Date.now(); +} diff --git a/fixtures/worker-app/src/explicit-resource-management.js b/fixtures/worker-app/src/explicit-resource-management.js new file mode 100644 index 0000000..5885674 --- /dev/null +++ b/fixtures/worker-app/src/explicit-resource-management.js @@ -0,0 +1,24 @@ +/** @param {string[]} logs */ +function connect(logs) { + logs.push("Connected"); + return { + send(message) { + logs.push(`Sent ${message}`); + }, + [Symbol.dispose]() { + logs.push("Disconnected synchronously"); + }, + async [Symbol.asyncDispose]() { + logs.push("Disconnected asynchronously"); + }, + }; +} + +/** @param {string[]} logs */ +export async function testExplicitResourceManagement(logs) { + using syncConnect = connect(logs); + await using asyncConnect = connect(logs); + + syncConnect.send("hello"); + asyncConnect.send("goodbye"); +} diff --git a/fixtures/worker-app/src/index.js b/fixtures/worker-app/src/index.js new file mode 100644 index 0000000..2ecac73 --- /dev/null +++ b/fixtures/worker-app/src/index.js @@ -0,0 +1,108 @@ +import { randomBytes } from "@fixture/isomorphic-random"; +import cookie from "cookie"; +import { now } from "./dep"; +import { testExplicitResourceManagement } from "./explicit-resource-management"; +import { logErrors } from "./log"; + +console.log("startup log"); + +console.log("The following error is a fake for testing"); +console.error( + "*** Received structured exception #0xc0000005: access violation; stack: 7ffe71872f57 7ff7834b643b 7ff7834b643b" +); + +/** @param {Uint8Array} array */ +function hexEncode(array) { + return Array.from(array) + .map((x) => x.toString(16).padStart(2, "0")) + .join(""); +} + +export default { + async fetch(request, env) { + console.log("request log"); + + const { pathname, origin, hostname, host } = new URL(request.url); + if (pathname.startsWith("/fav")) + return new Response("Not found", { status: 404 }); + if (pathname === "/env") return Response.json(env.FOO); + if (pathname === "/version_metadata") return Response.json(env.METADATA); + if (pathname === "/random") return new Response(hexEncode(randomBytes(8))); + if (pathname === "/error") throw new Error("Oops!"); + if (pathname === "/redirect") return Response.redirect(`${origin}/foo`); + if (pathname === "/cookie") + return new Response("", { + headers: [ + [ + "Set-Cookie", + cookie.serialize("hello", "world", { + domain: hostname, + }), + ], + [ + "Set-Cookie", + cookie.serialize("hello2", "world2", { + domain: host, + secure: true, + }), + ], + ], + }); + + if (pathname === "/content-encoding") { + return Response.json({ + AcceptEncoding: request.headers.get("Accept-Encoding"), + clientAcceptEncoding: request.cf.clientAcceptEncoding, + }); + } + if (pathname === "/content-encoding/gzip") { + return new Response("x".repeat(100), { + headers: { "Content-Encoding": "gzip" }, + }); + } + + if (pathname === "/explicit-resource-management") { + const logs = []; + await testExplicitResourceManagement(logs); + return Response.json(logs); + } + + if (request.headers.get("X-Test-URL") !== null) { + return new Response(request.url); + } + + console.log("METHOD =", request.method); + console.log("URL = ", request.url); + console.log("HEADERS =", new Map([...request.headers])); + console.log("CF =", request.cf); + + logErrors(); + + await fetch(new URL("https://example.com")); + await fetch( + new Request("https://example.com", { method: "POST", body: "foo" }) + ); + + console.log("end of request"); + return new Response( + `${request.url} ${now()} HOST:${request.headers.get( + "Host" + )} ORIGIN:${request.headers.get("Origin")}` + ); + }, + + /** + * Handle a scheduled event. + * + * If developing using `--local` mode, you can trigger this scheduled event via a CURL. + * E.g. `curl "http://localhost:8787/cdn-cgi/mf/scheduled"`. + * See the Miniflare docs: https://miniflare.dev/core/scheduled. + */ + scheduled(event, env, ctx) { + ctx.waitUntil(Promise.resolve(event.scheduledTime)); + ctx.waitUntil(Promise.resolve(event.cron)); + }, + tail(events) { + console.log("tails", { events }); + }, +}; diff --git a/fixtures/worker-app/src/log.ts b/fixtures/worker-app/src/log.ts new file mode 100644 index 0000000..a2f2ccc --- /dev/null +++ b/fixtures/worker-app/src/log.ts @@ -0,0 +1,17 @@ +// Make sure built JavaScript file doesn't use the same line numbers +interface Output { + thing: 42; +} + +export function logErrors(): Output { + console.log(new Error("logged error one")); + console.log(new Error("logged error two").stack); + console.log({ error: new Error("logged error three") }); + console.log({ nested: { error: new Error("logged error four").stack } }); + + console.log("some normal text to log"); + console.log("text with at in the middle"); + console.log("more text with at in the middle"); + + return { thing: 42 }; +} diff --git a/fixtures/worker-app/tests/build-conditions.test.ts b/fixtures/worker-app/tests/build-conditions.test.ts new file mode 100644 index 0000000..14587af --- /dev/null +++ b/fixtures/worker-app/tests/build-conditions.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "child_process"; +import { mkdtempSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { beforeEach, describe, it } from "vitest"; +import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived"; + +const basePath = resolve(__dirname, ".."); + +describe("'wrangler dev' with WRANGLER_BUILD_CONDITIONS", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "c3-wrangler-init--from-dash-")); + }); + + it("should import from the `other` package export if that is in the conditions", async ({ + expect, + }) => { + execFileSync( + "node", + [wranglerEntryPath, "deploy", "--dry-run", `--outdir=${tempDir}`], + { + env: { + ...process.env, + WRANGLER_BUILD_CONDITIONS: "other,node,browser", + }, + cwd: basePath, + } + ); + expect(readFileSync(resolve(tempDir, "index.js"), "utf8")).toContain( + "isomorphic-random-example/src/other.js" + ); + }); + + it("should import from the `default` package export if the conditions are explicitly empty", async ({ + expect, + }) => { + execFileSync( + "node", + [wranglerEntryPath, "deploy", "--dry-run", `--outdir=${tempDir}`], + { + env: { + ...process.env, + WRANGLER_BUILD_CONDITIONS: "", + }, + cwd: basePath, + } + ); + expect(readFileSync(resolve(tempDir, "index.js"), "utf8")).toContain( + "isomorphic-random-example/src/default.js" + ); + }); +}); + +describe("'wrangler build' with WRANGLER_BUILD_PLATFORM", () => { + it("should import from node imports if platform is set to 'node'", ({ + expect, + }) => { + execFileSync( + "node", + [wranglerEntryPath, "deploy", "--dry-run", "--outdir=dist/node"], + { + env: { + ...process.env, + WRANGLER_BUILD_PLATFORM: "node", + }, + cwd: basePath, + } + ); + expect( + readFileSync(resolve(__dirname, "../dist/node/index.js"), "utf8") + ).toContain("isomorphic-random-example/src/node.js"); + }); + + it("should import from node imports if platform is set to 'browser'", ({ + expect, + }) => { + execFileSync( + "node", + [wranglerEntryPath, "deploy", "--dry-run", "--outdir=dist/browser"], + { + env: { + ...process.env, + WRANGLER_BUILD_PLATFORM: "browser", + }, + cwd: basePath, + } + ); + expect( + readFileSync(resolve(__dirname, "../dist/browser/index.js"), "utf8") + ).toContain("../isomorphic-random-example/src/workerd.mjs"); + }); +}); diff --git a/fixtures/worker-app/tests/https.test.ts b/fixtures/worker-app/tests/https.test.ts new file mode 100644 index 0000000..312f265 --- /dev/null +++ b/fixtures/worker-app/tests/https.test.ts @@ -0,0 +1,33 @@ +import { resolve } from "path"; +import { Agent, fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("'wrangler dev' starts HTTPS server", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + "--local-protocol=https", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("allows access over HTTPS", async ({ expect }) => { + const dispatcher = new Agent({ + // `wrangler dev` will generate a self-signed certificate. By default, + // Node will reject these. Therefore, we need to use a custom + // dispatcher that accepts "invalid" certificates. + connect: { rejectUnauthorized: false }, + }); + const response = await fetch(`https://${ip}:${port}/random`, { + dispatcher, + }); + expect(response.ok).toBe(true); + }); +}); diff --git a/fixtures/worker-app/tests/index.test.ts b/fixtures/worker-app/tests/index.test.ts new file mode 100644 index 0000000..f3bd1b5 --- /dev/null +++ b/fixtures/worker-app/tests/index.test.ts @@ -0,0 +1,212 @@ +import { resolve } from "path"; +import { setTimeout } from "timers/promises"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it, vi } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("'wrangler dev' correctly renders pages", () => { + let ip: string, + port: number, + stop: (() => Promise) | undefined, + getOutput: () => string; + + beforeAll(async () => { + ({ ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + [ + "--port=0", + "--inspector-port=0", + "--upstream-protocol=https", + "--host=prod.example.org", + ] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("renders ", async ({ expect }) => { + await vi.waitFor( + async () => { + // Note that the local protocol defaults to http + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain(`https://prod.example.org/`); + }, + { interval: 1000, timeout: 5000 } + ); + + // Wait up to 5s for all request logs to be flushed + for (let i = 0; i < 10; i++) { + if (getOutput().includes("end of request")) break; + await setTimeout(500); + } + + // Ensure `console.log()`s from startup and requests are shown + const output = getOutput(); + expect(output).toContain("startup log"); + expect(output).toContain("request log"); + + if (process.platform === "win32") { + // Check that the Windows warning is shown for the fake access violation error + expect(output).toContain( + "On Windows, this may be caused by an outdated Microsoft Visual C++ Redistributable library." + ); + } + + // check host on request in the Worker is as expected + expect(output).toContain(`host' => 'prod.example.org`); + + // Check logged strings are source mapped + expect(output).toMatch( + /Error: logged error one\n.+at logErrors.+fixtures\/worker-app\/src\/log\.ts:7:14/ + ); + expect(output).toMatch( + /Error: logged error two\n.+at logErrors.+fixtures\/worker-app\/src\/log\.ts:8:14/ + ); + expect(output).toMatch( + /Error: logged error three\n.+at logErrors.+fixtures\/worker-app\/src\/log\.ts:9:23/ + ); + expect(output).toMatch( + /Error: logged error four\\n' \+\n.+at logErrors.+fixtures\/worker-app\/src\/log\.ts:10:33/ + ); + + // Regression test for https://github.com/cloudflare/workers-sdk/issues/4668 + expect(output).toContain("some normal text to log"); + expect(output).toContain("text with at in the middle"); + expect(output).toContain("more text with at in the middle"); + }); + + it("renders pretty error after logging", async ({ expect }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/4715 + const response = await fetch(`http://${ip}:${port}/error`); + const text = await response.text(); + expect(text).toContain("Oops!"); + expect(response.headers.get("Content-Type")).toBe( + "text/html;charset=utf-8" + ); + }); + + it("uses `workerd` condition when bundling", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/random`); + const text = await response.text(); + expect(text).toMatch(/[0-9a-f]{16}/); // 8 hex bytes + }); + + it("passes through URL unchanged", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}//thing?a=1`, { + headers: { "X-Test-URL": "true" }, + }); + const text = await response.text(); + expect(text).toBe(`https://prod.example.org//thing?a=1`); + }); + + it("rewrites the Host and Origin headers appropriately", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/test`, { + // Pass in an Origin header to trigger the rewriting + headers: { Origin: `http://${ip}:${port}` }, + }); + const text = await response.text(); + expect(text).toContain(`HOST:prod.example.org`); + expect(text).toContain(`ORIGIN:https://prod.example.org`); + }); + + it("does not rewrite Origin header if one is not passed by the client", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/test`, {}); + const text = await response.text(); + expect(text).toContain(`HOST:prod.example.org`); + expect(text).toContain(`ORIGIN:null`); + }); + + it("does not rewrite Origin header if it not the same origin as the proxy Worker", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/test`, { + headers: { Origin: `http://foo.com` }, + }); + const text = await response.text(); + expect(text).toContain(`HOST:prod.example.org`); + expect(text).toContain(`ORIGIN:http://foo.com`); + }); + + it("rewrites response headers containing the emulated host", async ({ + expect, + }) => { + // This /redirect request will add a Location header that points to prod.example.com/foo + // But we should rewrite this back to that of the proxy. + const response = await fetch(`http://${ip}:${port}/redirect`, { + redirect: "manual", + }); + expect(response.status).toBe(302); + expect(await response.text()).toEqual(""); + expect(response.headers.get("Location")).toEqual( + `http://${ip}:${port}/foo` + ); + }); + + it("rewrites set-cookie headers to the hostname, not host", async ({ + expect, + }) => { + const response = await fetch(`http://${ip}:${port}/cookie`); + + expect(response.headers.getSetCookie()).toStrictEqual([ + `hello=world; Domain=${ip}`, + `hello2=world2; Domain=${ip}; Secure`, + ]); + }); + + it("has access to version_metadata binding", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/version_metadata`); + + await expect(response.json()).resolves.toMatchObject({ + id: expect.any(String), + tag: expect.any(String), + }); + }); + + it("passes through client content encoding", async ({ expect }) => { + // https://github.com/cloudflare/workers-sdk/issues/5246 + const response = await fetch(`http://${ip}:${port}/content-encoding`, { + headers: { "Accept-Encoding": "hello" }, + }); + expect(await response.json()).toStrictEqual({ + AcceptEncoding: "br, gzip", + clientAcceptEncoding: "hello", + }); + }); + + it("supports encoded responses", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/content-encoding/gzip`, { + headers: { "Accept-Encoding": "gzip" }, + }); + expect(await response.text()).toEqual("x".repeat(100)); + }); + + it("uses explicit resource management", async ({ expect }) => { + const response = await fetch( + `http://${ip}:${port}/explicit-resource-management` + ); + expect(await response.json()).toMatchInlineSnapshot(` + [ + "Connected", + "Connected", + "Sent hello", + "Sent goodbye", + "Disconnected asynchronously", + "Disconnected synchronously", + ] + `); + }); + + it("reads local dev vars from the .env file", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/env`); + const env = await response.text(); + expect(env).toBe(`"bar"`); + }); +}); diff --git a/fixtures/worker-app/tests/tsconfig.json b/fixtures/worker-app/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/worker-app/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/worker-app/tests/undrained-body.test.ts b/fixtures/worker-app/tests/undrained-body.test.ts new file mode 100644 index 0000000..18da5d6 --- /dev/null +++ b/fixtures/worker-app/tests/undrained-body.test.ts @@ -0,0 +1,44 @@ +import { resolve } from "path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("wrangler dev", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + // https://github.com/cloudflare/workers-sdk/issues/5095 + it("should not fail requests if the Worker does not drain the body", async ({ + expect, + }) => { + const COUNT = 30; + const requests: boolean[] = []; + const errors: string[] = []; + + const body = new Uint8Array(2_000); + for (let i = 0; i < COUNT; i++) { + const response = await fetch(`http://${ip}:${port}/random`, { + method: "POST", + body, + }); + requests.push(response.ok); + if (!response.ok) { + errors.push(await response.text()); + } + } + + expect(requests.length).toBe(COUNT); + expect(errors).toEqual([]); + expect(requests).toEqual(Array.from({ length: COUNT }).map((i) => true)); + }); +}); diff --git a/fixtures/worker-app/tsconfig.json b/fixtures/worker-app/tsconfig.json new file mode 100644 index 0000000..7480e11 --- /dev/null +++ b/fixtures/worker-app/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2020"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/worker-app/vitest.config.mts b/fixtures/worker-app/vitest.config.mts new file mode 100644 index 0000000..a2ea8a0 --- /dev/null +++ b/fixtures/worker-app/vitest.config.mts @@ -0,0 +1,17 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + // The three test files in this fixture each spawn their own + // `wrangler dev` against the same working directory, so they share + // the `.wrangler/state` SQLite cache. Running them in parallel hits + // `SQLITE_BUSY` intermittently and causes workerd to abort with + // "The Workers runtime failed to start", leaving tests to fail with + // ECONNREFUSED. Run the files serially to avoid the contention. + fileParallelism: false, + }, + }) +); diff --git a/fixtures/worker-app/wrangler.jsonc b/fixtures/worker-app/wrangler.jsonc new file mode 100644 index 0000000..ca2eeaa --- /dev/null +++ b/fixtures/worker-app/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "name": "worker-app", + "compatibility_date": "2022-03-31", + "main": "src/index.js", + "triggers": { + "crons": ["1 * * * *"], + }, + "version_metadata": { + "binding": "METADATA", + }, +} diff --git a/fixtures/worker-logs/package.json b/fixtures/worker-logs/package.json new file mode 100644 index 0000000..8123c78 --- /dev/null +++ b/fixtures/worker-logs/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/worker-logs", + "private": true, + "scripts": { + "check:type": "tsc", + "dev": "pnpm dev.module", + "dev.module": "wrangler dev -c wrangler.module.jsonc", + "dev.service": "wrangler dev -c wrangler.service.jsonc", + "start": "pnpm dev.module", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/worker-logs/src/module.js b/fixtures/worker-logs/src/module.js new file mode 100644 index 0000000..06008fe --- /dev/null +++ b/fixtures/worker-logs/src/module.js @@ -0,0 +1,28 @@ +export default { + async fetch(request, env) { + const response = new Response("Hello"); + + const customMessage = request.headers.get("x-custom-message"); + if (customMessage) { + if (customMessage === "%__VERY_VERY_LONG_MESSAGE_%") { + // We can't simply pass a huge long message as a header thus + // why a placeholder is used here + console.log("<<<<< " + "z".repeat(2 ** 20) + " >>>>>"); + } else { + console.log("<<<<< " + customMessage + " >>>>>"); + } + return response; + } + + console.log("<<<<< console.log() message >>>>>"); + console.warn("<<<<< console.warning() message >>>>>"); + console.error("<<<<< console.error() message >>>>>"); + console.debug("<<<<< console.debug() message >>>>>"); + console.info("<<<<< console.info() message >>>>>"); + + process.stderr.write("<<<<< stderr.write() message >>>>>\n"); + process.stdout.write("<<<<< stdout.write() message >>>>>\n"); + + return response; + }, +}; diff --git a/fixtures/worker-logs/src/service.js b/fixtures/worker-logs/src/service.js new file mode 100644 index 0000000..8fa27db --- /dev/null +++ b/fixtures/worker-logs/src/service.js @@ -0,0 +1,12 @@ +async function handler(request) { + console.log("<<<<< console.log() message >>>>>"); + console.warn("<<<<< console.warning() message >>>>>"); + console.error("<<<<< console.error() message >>>>>"); + console.debug("<<<<< console.debug() message >>>>>"); + console.info("<<<<< console.info() message >>>>>"); + return new Response("Hello"); +} + +addEventListener("fetch", (event) => { + event.respondWith(handler(event.request)); +}); diff --git a/fixtures/worker-logs/tests/index.test.ts b/fixtures/worker-logs/tests/index.test.ts new file mode 100644 index 0000000..3bfc753 --- /dev/null +++ b/fixtures/worker-logs/tests/index.test.ts @@ -0,0 +1,345 @@ +import { stripVTControlCharacters } from "node:util"; +import { resolve } from "path"; +import { describe, onTestFinished, test, vi } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +/** + * Run a Worker, make a request to it, and capture the output logs. + * + * You can call the returned function repeatedly to get the currently captured logs. + * The Worker will be stopped automatically when the test finishes. + * + * @param type The type of worker: "module" or "service". + * @param extraArgs Additional command-line arguments to pass to `wrangler dev`. + * @param customMessage A custom message to include in the `x-custom-message` header. + * @param env Environment variables to set for the worker process + * @returns A function that returns the captured output logs when called + */ +async function getWranglerDevOutput( + type: "module" | "service", + extraArgs: string[] = [], + customMessage?: string, + env = {} +) { + const { ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + [ + `-c=wrangler.${type}.jsonc`, + "--port=0", + "--inspector-port=0", + ...extraArgs, + ], + env + ); + + onTestFinished(() => stop()); + + const request = new Request(`http://${ip}:${port}`); + if (customMessage) { + request.headers.set("x-custom-message", customMessage); + } + + const response = await fetch(request); + await response.text(); + + return () => { + const output = stripVTControlCharacters(getOutput()) + // Windows gets a different marker for ✘, so let's normalize it here + // so that these tests can be platform independent + .replaceAll("✘", "X") + // Let's also normalize Windows newlines + .replaceAll("\r\n", "\n"); + + // Let's filter out lines we're not interested in + const messages = Array.from(output.matchAll(/^.*<<<<<.*>>>>>$/gm)).flat(); + // Let's also sort the logs for more stability of the tests. + // Ideally we would want to test the log's ordering as well but that seems + // to cause flakes in the CI runs + return messages.sort(); + }; +} + +describe("'wrangler dev' correctly displays logs", () => { + describe("module workers", () => { + test("default behavior", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module"); + await vi.waitFor( + () => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< stderr.write() message >>>>>", + "<<<<< stdout.write() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]), + { timeout: 5000 } + ); + }); + + test("with --log-level=log", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=log", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< stderr.write() message >>>>>", + "<<<<< stdout.write() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=info", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=info", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=warn", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=warn", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=error", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=error", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "X [ERROR] <<<<< console.error() message >>>>>", + ]) + ); + }); + + test("with --log-level=debug", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=debug", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< stderr.write() message >>>>>", + "<<<<< stdout.write() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + + test('with WRANGLER_LOG="debug"', async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [], undefined, { + WRANGLER_LOG: "debug", + }); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< stderr.write() message >>>>>", + "<<<<< stdout.write() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=none", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--log-level=none", + ]); + await vi.waitFor(() => expect(getOutput()).toEqual([])); + }); + + // the workerd structured logs follow this structure: + // {"timestamp":,"level":"","message":""} + // the following tests check for edge case scenario where the following + // structure could not get detected correctly + describe("edge case scenarios", () => { + test("base case", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [], "hello"); + await vi.waitFor(() => + expect(getOutput()).toEqual(["<<<<< hello >>>>>"]) + ); + }); + test("quotes in message", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [], 'hel"lo'); + await vi.waitFor(() => + expect(getOutput()).toEqual(['<<<<< hel"lo >>>>>']) + ); + }); + + test("braces in message", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [], "hel{}lo"); + await vi.waitFor(() => + expect(getOutput()).toEqual(["<<<<< hel{}lo >>>>>"]) + ); + }); + + test("a workerd structured message in the message", async ({ + expect, + }) => { + const getOutput = await getWranglerDevOutput( + "module", + [], + 'This is an example of a Workerd structured log: {"timestamp":1234567890,"level":"log","message":"Hello World!"}' + ); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + '<<<<< This is an example of a Workerd structured log: {"timestamp":1234567890,"level":"log","message":"Hello World!"} >>>>>', + ]) + ); + }); + + test("a very very very long message (that gets split in multiple chunks)", async ({ + expect, + }) => { + const getOutput = await getWranglerDevOutput( + "module", + [], + "%__VERY_VERY_LONG_MESSAGE_%" + ); + await vi.waitFor(() => + expect(getOutput()).toContain( + "<<<<< " + "z".repeat(2 ** 20) + " >>>>>" + ) + ); + }); + }); + }); + + // Note: service workers logs are handled differently from standard logs (and are built on top of + // inspector Runtime.consoleAPICalled events), they don't work as well as logs for module + // workers. Service workers are also deprecated so it's not a huge deal, the following + // tests are only here in place to make sure that the basic logging functionality of + // service workers does work + describe("service workers", () => { + test("default behavior", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service"); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.error() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=log", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=log", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.error() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=info", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=info", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.error() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=warn", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=warn", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.error() message >>>>>", + "<<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=error", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=error", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual(["<<<<< console.error() message >>>>>"]) + ); + }); + + test("with --log-level=debug", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=debug", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.error() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "<<<<< console.warning() message >>>>>", + ]) + ); + }); + + test("with --log-level=none", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("service", [ + "--log-level=none", + ]); + await vi.waitFor(() => expect(getOutput()).toEqual([])); + }); + }); + + describe("nodejs compat process v2", () => { + test("default behavior", async ({ expect }) => { + const getOutput = await getWranglerDevOutput("module", [ + "--compatibility-flags=enable_nodejs_process_v2", + "--compatibility-flags=nodejs_compat", + // Those flags are needed to enable the new process.v2 implementation + // See `getProcessOverrides` in `packages/unenv-preset/src/preset.ts` + "--compatibility-flags=fetch_iterable_type_support", + "--compatibility-flags=fetch_iterable_type_support_override_adjustment", + ]); + await vi.waitFor(() => + expect(getOutput()).toEqual([ + "<<<<< console.debug() message >>>>>", + "<<<<< console.info() message >>>>>", + "<<<<< console.log() message >>>>>", + "X [ERROR] <<<<< console.error() message >>>>>", + "stderr: <<<<< stderr.write() message >>>>>", + "stdout: <<<<< stdout.write() message >>>>>", + "▲ [WARNING] <<<<< console.warning() message >>>>>", + ]) + ); + }); + }); +}); diff --git a/fixtures/worker-logs/tsconfig.json b/fixtures/worker-logs/tsconfig.json new file mode 100644 index 0000000..5bccae9 --- /dev/null +++ b/fixtures/worker-logs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "esModuleInterop": true, + "module": "preserve", + "lib": ["ES2021"], + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "node", + "noEmit": true + }, + "include": ["tests"] +} diff --git a/fixtures/worker-logs/turbo.json b/fixtures/worker-logs/turbo.json new file mode 100644 index 0000000..789f1de --- /dev/null +++ b/fixtures/worker-logs/turbo.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test:ci": { + "env": [ + "VITEST", + "NODE_DEBUG", + "MINIFLARE_WORKERD_PATH", + "WRANGLER", + "WRANGLER_IMPORT", + "MINIFLARE_IMPORT", + "TEST_CLOUDFLARE_ACCOUNT_ID", + "TEST_CLOUDFLARE_API_TOKEN", + "WRANGLER_E2E_TEST_FILE" + ] + } + } +} diff --git a/fixtures/worker-logs/vitest.config.mts b/fixtures/worker-logs/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/worker-logs/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/worker-logs/wrangler.module.jsonc b/fixtures/worker-logs/wrangler.module.jsonc new file mode 100644 index 0000000..18d451c --- /dev/null +++ b/fixtures/worker-logs/wrangler.module.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-logs", + "compatibility_date": "2026-01-01", + "main": "src/module.js", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/fixtures/worker-logs/wrangler.service.jsonc b/fixtures/worker-logs/wrangler.service.jsonc new file mode 100644 index 0000000..9507931 --- /dev/null +++ b/fixtures/worker-logs/wrangler.service.jsonc @@ -0,0 +1,5 @@ +{ + "name": "worker-logs", + "compatibility_date": "2022-03-31", + "main": "src/service.js", +} diff --git a/fixtures/worker-ts/CHANGELOG.md b/fixtures/worker-ts/CHANGELOG.md new file mode 100644 index 0000000..912ae7b --- /dev/null +++ b/fixtures/worker-ts/CHANGELOG.md @@ -0,0 +1,7 @@ +# worker-ts + +## 0.0.1 + +### Patch Changes + +- [#3588](https://github.com/cloudflare/workers-sdk/pull/3588) [`64631d8b`](https://github.com/cloudflare/workers-sdk/commit/64631d8b59572f49d65325d8f6fec098c5e912b9) Thanks [@penalosa](https://github.com/penalosa)! - fix: Preserve email handlers when applying middleware to user workers. diff --git a/fixtures/worker-ts/package.json b/fixtures/worker-ts/package.json new file mode 100644 index 0000000..ed385e0 --- /dev/null +++ b/fixtures/worker-ts/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/worker-typescript", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/worker-ts/src/index.ts b/fixtures/worker-ts/src/index.ts new file mode 100644 index 0000000..6a010a4 --- /dev/null +++ b/fixtures/worker-ts/src/index.ts @@ -0,0 +1,34 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `wrangler dev src/index.ts` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `wrangler deploy src/index.ts --name my-worker` to deploy your worker + * + * Learn more at https://developers.cloudflare.com/workers/ + */ +export interface Env { + // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ + // MY_KV_NAMESPACE: KVNamespace; + // + // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ + // MY_DURABLE_OBJECT: DurableObjectNamespace; + // + // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ + // MY_BUCKET: R2Bucket; + // + // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ + // MY_SERVICE: Fetcher; +} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise { + const url = new URL(request.url); + if (url.pathname === "/error") throw new Error("Hello Error"); + return new Response("Hello World!"); + }, +}; diff --git a/fixtures/worker-ts/tsconfig.json b/fixtures/worker-ts/tsconfig.json new file mode 100644 index 0000000..2431bac --- /dev/null +++ b/fixtures/worker-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "types": ["@cloudflare/workers-types/experimental"], + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/fixtures/worker-ts/wrangler.jsonc b/fixtures/worker-ts/wrangler.jsonc new file mode 100644 index 0000000..25cff9c --- /dev/null +++ b/fixtures/worker-ts/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "worker-ts", + "main": "src/index.ts", + "compatibility_date": "2023-05-04", +} diff --git a/fixtures/worker-with-resources/package.json b/fixtures/worker-with-resources/package.json new file mode 100644 index 0000000..180b314 --- /dev/null +++ b/fixtures/worker-with-resources/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fixture/worker-with-resources", + "private": true, + "scripts": { + "cf-typegen": "wrangler types --no-include-runtime && wrangler types ./worker-b/worker-configuration.d.ts -c ./worker-b/wrangler.jsonc --no-include-runtime", + "deploy": "wrangler deploy", + "start": "X_LOCAL_EXPLORER=true wrangler dev", + "start:worker-b": "X_LOCAL_EXPLORER=true wrangler dev -c worker-b/wrangler.jsonc", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:^", + "@cloudflare/workers-types": "catalog:default", + "miniflare": "workspace:*", + "typescript": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/worker-with-resources/src/index.ts b/fixtures/worker-with-resources/src/index.ts new file mode 100644 index 0000000..125d2ec --- /dev/null +++ b/fixtures/worker-with-resources/src/index.ts @@ -0,0 +1,371 @@ +import { DurableObject, WorkflowEntrypoint } from "cloudflare:workers"; +import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + switch (url.pathname) { + // KV routes + case "/kv/get": { + const keyToGet = url.searchParams.get("key") ?? "default"; + const value = await env.KV.get(keyToGet); + return new Response(value || "null"); + } + case "/kv/put": { + const keyToSet = url.searchParams.get("key") ?? "default"; + const val = url.searchParams.get("value"); + await env.KV.put(keyToSet, val); + return new Response("OK"); + } + case "/kv/delete": { + const keyToDelete = url.searchParams.get("key") ?? "default"; + await env.KV.delete(keyToDelete); + return new Response(`Deleted key ${keyToDelete} from KV`); + } + case "/kv/seed": { + await Promise.all(SEED_DATA.map(([k, v]) => env.KV.put(k, v))); + return new Response(`Seeded ${SEED_DATA.length} KV entries`); + } + + // R2 routes + case "/r2/seed": { + await Promise.all( + R2_SEED_DATA.map(({ key, content, contentType, customMetadata }) => + env.BUCKET.put(key, content, { + httpMetadata: { contentType }, + customMetadata, + }) + ) + ); + return new Response(`Seeded ${R2_SEED_DATA.length} R2 objects`); + } + + // D1 database route + case "/d1": { + await env.DB.exec(` + DROP TABLE IF EXISTS users; + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob'); + `); + return new Response("OK"); + } + // Durable Object SQLite routes + case "/do": { + const id = url.searchParams.get("id") || "default"; + const doId = env.DO.idFromName(id); + const stub = env.DO.get(doId); + return stub.fetch(request); + } + } + return new Response("Hello World!"); + }, +}; + +export class MyDurableObject extends DurableObject { + async fetch(_request: Request): Promise { + this.ctx.storage.sql.exec(` + DROP TABLE IF EXISTS users; + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob'); + `); + return new Response("OK"); + } +} + +interface WorkflowParams { + name: string; +} + +export class MyWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep) { + const name = event.payload.name ?? "World"; + + const greeting = await step.do("greet", async () => `Hello, ${name}!`); + + await step.sleep("wait", "1 second"); + + const result = await step.do("finalize", async () => { + return `${greeting} Workflow complete.`; + }); + + return result; + } +} + +const SEED_DATA: [string, string][] = [ + ["greeting", "Hello, World!"], + ["counter", "42"], + ["config:theme", "dark"], + ["config:language", "en-US"], + ["config:timezone", "America/New_York"], + ["config:debug", "false"], + ["config:max-retries", "3"], + ["feature:dark-mode", "enabled"], + ["feature:beta-ui", "disabled"], + ["feature:notifications", "enabled"], + ["api:rate-limit", "1000"], + ["api:timeout-ms", "30000"], + ["api:base-url", "https://api.example.com/v1"], + [ + "user:1", + JSON.stringify({ + id: 1, + name: "Alice", + email: "alice@example.com", + role: "admin", + }), + ], + [ + "user:2", + JSON.stringify({ + id: 2, + name: "Bob", + email: "bob@example.com", + role: "user", + }), + ], + [ + "user:3", + JSON.stringify({ + id: 3, + name: "Charlie", + email: "charlie@example.com", + role: "user", + }), + ], + [ + "user:4", + JSON.stringify({ + id: 4, + name: "Diana", + email: "diana@example.com", + role: "moderator", + }), + ], + [ + "user:5", + JSON.stringify({ + id: 5, + name: "Eve", + email: "eve@example.com", + role: "user", + }), + ], + [ + "session:abc123", + JSON.stringify({ userId: 1, expiresAt: "2025-12-31T23:59:59Z" }), + ], + [ + "session:def456", + JSON.stringify({ userId: 2, expiresAt: "2025-06-15T12:00:00Z" }), + ], + [ + "session:ghi789", + JSON.stringify({ userId: 3, expiresAt: "2025-03-01T08:30:00Z" }), + ], + ["cache:homepage", "

Welcome

"], + ["cache:about", "

About Us

"], + ["cache:contact", "

Contact

"], + [ + "product:1", + JSON.stringify({ id: 1, name: "Widget", price: 9.99, stock: 150 }), + ], + [ + "product:2", + JSON.stringify({ id: 2, name: "Gadget", price: 24.99, stock: 75 }), + ], + [ + "product:3", + JSON.stringify({ id: 3, name: "Gizmo", price: 14.99, stock: 200 }), + ], + [ + "product:4", + JSON.stringify({ id: 4, name: "Thingamajig", price: 39.99, stock: 30 }), + ], + [ + "product:5", + JSON.stringify({ id: 5, name: "Doohickey", price: 19.99, stock: 100 }), + ], + ["stats:visits", "123456"], + ["stats:unique-users", "45678"], + ["stats:page-views", "987654"], + ["stats:bounce-rate", "0.35"], + ["stats:avg-session", "4m 32s"], + [ + "log:error:1", + JSON.stringify({ + level: "error", + message: "Connection timeout", + timestamp: "2025-01-15T10:30:00Z", + }), + ], + [ + "log:error:2", + JSON.stringify({ + level: "error", + message: "Invalid token", + timestamp: "2025-01-15T11:45:00Z", + }), + ], + [ + "log:warn:1", + JSON.stringify({ + level: "warn", + message: "Rate limit approaching", + timestamp: "2025-01-15T12:00:00Z", + }), + ], + ["queue:pending", "15"], + ["queue:processing", "3"], + ["queue:completed", "1247"], + ["queue:failed", "12"], + ["special/key/with/slashes", "value with slashes in key"], + ["key with spaces", "value for spaced key"], + ["key.with.dots", "dotted key value"], + ["UPPERCASE_KEY", "uppercase key value"], + ["mixedCase_Key-123", "mixed case key value"], + [ + "long-value-example", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + ], + ["emoji-key-🔑", "value with emoji in key"], + ["empty-value", ""], + ["null-value", null], + ["boolean-true", "true"], + ["boolean-false", "false"], + ["number-integer", "42"], + ["number-float", "3.14159"], + ["number-negative", "-273.15"], + ["large-key-1", "x".repeat(10 * 1024 * 1024)], +]; + +interface R2SeedItem { + key: string; + content: string | ArrayBuffer; + contentType: string; + customMetadata?: Record; +} + +const R2_SEED_DATA: R2SeedItem[] = [ + // Root level files + { + key: "readme.txt", + content: "Welcome to the R2 bucket! This is a sample readme file.", + contentType: "text/plain", + }, + { + key: "config.json", + content: JSON.stringify( + { version: "1.0.0", environment: "development" }, + null, + 2 + ), + contentType: "application/json", + customMetadata: { author: "admin", created: "2025-01-15" }, + }, + + // Images folder + { + key: "images/logo.svg", + content: + '', + contentType: "image/svg+xml", + }, + { + key: "images/banner.svg", + content: + '', + contentType: "image/svg+xml", + }, + { + key: "images/icons/home.svg", + content: + '', + contentType: "image/svg+xml", + }, + { + key: "images/icons/settings.svg", + content: + '', + contentType: "image/svg+xml", + }, + + // Documents folder + { + key: "documents/report.txt", + content: + "Annual Report 2024\n\nThis is a sample annual report with important business metrics.", + contentType: "text/plain", + customMetadata: { department: "finance", year: "2024" }, + }, + { + key: "documents/notes.md", + content: + "# Meeting Notes\n\n## Action Items\n- Review budget\n- Update roadmap\n- Schedule follow-up", + contentType: "text/markdown", + }, + { + key: "documents/data.csv", + content: "id,name,value\n1,Alpha,100\n2,Beta,200\n3,Gamma,300", + contentType: "text/csv", + }, + + // Data folder with nested structure + { + key: "data/users.json", + content: JSON.stringify( + [ + { id: 1, name: "Alice", role: "admin" }, + { id: 2, name: "Bob", role: "user" }, + ], + null, + 2 + ), + contentType: "application/json", + }, + { + key: "data/backup/2024/january.json", + content: JSON.stringify({ month: "January", records: 150 }), + contentType: "application/json", + customMetadata: { backup: "true", period: "2024-01" }, + }, + { + key: "data/backup/2024/february.json", + content: JSON.stringify({ month: "February", records: 175 }), + contentType: "application/json", + customMetadata: { backup: "true", period: "2024-02" }, + }, + + // Logs folder + { + key: "logs/access.log", + content: + "2025-01-15 10:00:00 GET /api/users 200\n2025-01-15 10:01:00 POST /api/login 200\n2025-01-15 10:02:00 GET /api/products 200", + contentType: "text/plain", + }, + { + key: "logs/error.log", + content: + "2025-01-15 09:30:00 ERROR Connection timeout\n2025-01-15 09:45:00 ERROR Invalid token", + contentType: "text/plain", + }, + + // Assets with various content types + { + key: "assets/styles.css", + content: + "body { font-family: sans-serif; }\n.container { max-width: 1200px; margin: 0 auto; }", + contentType: "text/css", + }, + { + key: "assets/script.js", + content: 'console.log("Hello from R2!");\nfunction init() { return true; }', + contentType: "application/javascript", + }, + { + key: "assets/template.html", + content: + "\nTemplate

Hello

", + contentType: "text/html", + }, +]; diff --git a/fixtures/worker-with-resources/tests/index.test.ts b/fixtures/worker-with-resources/tests/index.test.ts new file mode 100644 index 0000000..4930b1f --- /dev/null +++ b/fixtures/worker-with-resources/tests/index.test.ts @@ -0,0 +1,199 @@ +import { resolve } from "path"; +import { CorePaths } from "miniflare"; +import { afterAll, assert, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +const EXPLORER_API_PATH = `${CorePaths.EXPLORER}/api`; + +describe("local explorer", () => { + let ip: string; + let port: number; + let stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + it(`returns local explorer API response for ${EXPLORER_API_PATH}`, async ({ + expect, + }) => { + const response = await fetch( + `http://${ip}:${port}${EXPLORER_API_PATH}/storage/kv/namespaces` + ); + expect(response.headers.get("Content-Type")).toBe("application/json"); + const json = await response.json(); + expect(json).toMatchObject({ + errors: [], + messages: [], + result: [ + { + id: "KV", + title: "KV", + }, + { + id: "some-kv-id", + title: "KV_WITH_ID", + }, + { + id: "worker-b-kv-id", + title: "KV_B", + }, + ], + result_info: { + count: 3, + }, + success: true, + }); + }); + + it("returns worker response for normal requests", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toBe("Hello World!"); + }); + + it(`serves UI index.html at ${CorePaths.EXPLORER}`, async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}${CorePaths.EXPLORER}`); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8" + ); + const text = await response.text(); + expect(text).toContain(""); + expect(text).toContain("Cloudflare Local Explorer"); + }); + + it(`serves UI assets at ${CorePaths.EXPLORER}/assets/*`, async ({ + expect, + }) => { + // First get index.html to find the actual asset paths + const indexResponse = await fetch( + `http://${ip}:${port}${CorePaths.EXPLORER}` + ); + const html = await indexResponse.text(); + + // Extract JS asset path from the HTML + // The HTML looks like: + + + + + + + diff --git a/fixtures/workers-with-assets-spa/public/shadowed-by-asset.txt b/fixtures/workers-with-assets-spa/public/shadowed-by-asset.txt new file mode 100644 index 0000000..1190b77 --- /dev/null +++ b/fixtures/workers-with-assets-spa/public/shadowed-by-asset.txt @@ -0,0 +1 @@ +i'm some text! \ No newline at end of file diff --git a/fixtures/workers-with-assets-spa/src/index.ts b/fixtures/workers-with-assets-spa/src/index.ts new file mode 100644 index 0000000..f1cf871 --- /dev/null +++ b/fixtures/workers-with-assets-spa/src/index.ts @@ -0,0 +1,24 @@ +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/api/math") { + return new Response(`1 + 1 = ${1 + 1}`); + } + if (url.pathname === "/api/json") { + return Response.json({ hello: "world" }); + } + if (url.pathname === "/api/html") { + return new Response("

Hello, world!

", { + headers: { "Content-Type": "text/html" }, + }); + } + if (url.pathname === "/shadowed-by-spa") { + return new Response("nope"); + } + if (url.pathname === "/shadowed-by-asset.txt") { + return new Response("nope"); + } + + return new Response("nope", { status: 404 }); + }, +}; diff --git a/fixtures/workers-with-assets-spa/tests/__image_snapshots__/index-test-ts-workers-assets-spa-renders-the-homepage-in-a-browser-correctly-1-snap.png b/fixtures/workers-with-assets-spa/tests/__image_snapshots__/index-test-ts-workers-assets-spa-renders-the-homepage-in-a-browser-correctly-1-snap.png new file mode 100644 index 0000000..d1b8b3d Binary files /dev/null and b/fixtures/workers-with-assets-spa/tests/__image_snapshots__/index-test-ts-workers-assets-spa-renders-the-homepage-in-a-browser-correctly-1-snap.png differ diff --git a/fixtures/workers-with-assets-spa/tests/index.test.ts b/fixtures/workers-with-assets-spa/tests/index.test.ts new file mode 100644 index 0000000..a559c06 --- /dev/null +++ b/fixtures/workers-with-assets-spa/tests/index.test.ts @@ -0,0 +1,267 @@ +import { resolve } from "node:path"; +import { toMatchImageSnapshot } from "jest-image-snapshot"; +import { Browser, chromium } from "playwright-chromium"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Workers + Assets + SPA", () => { + let ip: string, + port: number, + stop: (() => Promise) | undefined, + getOutput: () => string; + let browser: Browser | undefined; + + beforeAll(async () => { + ({ ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + )); + + browser = await chromium.launch({ + headless: !process.env.VITE_DEBUG_SERVE, + args: process.env.CI + ? ["--no-sandbox", "--disable-setuid-sandbox"] + : undefined, + }); + }); + + afterAll(async () => { + await stop?.(); + await browser?.close(); + }); + + it("renders the homepage in a browser correctly", async ({ expect }) => { + expect.extend({ toMatchImageSnapshot }); + + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/"); + if (process.platform === "darwin") { + // different platforms render the page differently (fonts?) + expect(await page.screenshot()).toMatchImageSnapshot({ + failureThreshold: 0.02, + failureThresholdType: "percent", + }); + } + + const mathResultLocator = page.getByText("1 + 1 = 2"); + await mathResultLocator.waitFor({ state: "attached" }); + expect(await mathResultLocator.getAttribute("id")).toBe(`math-result`); + + const jsonResultLocator = page.getByText( + JSON.stringify({ hello: "world" }, null, 2) + ); + await jsonResultLocator.waitFor({ state: "attached" }); + expect(await jsonResultLocator.getAttribute("id")).toBe(`json-result`); + + const htmlResultLocator = page.getByText("Hello, world!"); + await htmlResultLocator.waitFor({ state: "attached" }); + const parentHtmlResultLocator = htmlResultLocator.locator(".."); + expect(await parentHtmlResultLocator.getAttribute("id")).toBe( + "html-result" + ); + }); + + it("navigates soft page navigations correctly", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/"); + + const homepageLink = page.getByText("/ HARD SOFT").getByText("SOFT"); + expect(await homepageLink.getAttribute("href")).toBe("/"); + await homepageLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/`); + const homepageHeader = page.getByRole("heading", { name: "Homepage" }); + await homepageHeader.waitFor({ state: "attached" }); + + const blogLink = page.getByText("/blog HARD SOFT").getByText("SOFT"); + expect(await blogLink.getAttribute("href")).toBe("/blog"); + await blogLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/blog`); + const blogTitleLocator = page.getByRole("heading", { name: "Blog" }); + await blogTitleLocator.waitFor({ state: "attached" }); + + const blogSlugInput = page.getByRole("textbox"); + blogSlugInput.fill("/blog/some-slug-here"); + + const blogSlugLink = page.getByRole("button", { name: "SOFT load" }); + await blogSlugLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/blog/some-slug-here`); + const blogSlugTitleLocator = page.getByRole("heading", { + name: "Blog | some-slug-here", + }); + await blogSlugTitleLocator.waitFor({ state: "attached" }); + + const blogRandomLink = page + .getByText("/blog/random HARD SOFT") + .getByText("SOFT"); + expect(await blogRandomLink.getAttribute("href")).toBe("/blog/random"); + await blogRandomLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/blog/random`); + const blogRandomTitleLocator = page.getByRole("heading", { + name: "Blog | random", + }); + await blogRandomTitleLocator.waitFor({ state: "attached" }); + + const shadowedByAssetLink = page + .getByText("/shadowed-by-asset.txt HARD SOFT") + .getByText("SOFT"); + expect(await shadowedByAssetLink.getAttribute("href")).toBe( + "/shadowed-by-asset.txt" + ); + await shadowedByAssetLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/shadowed-by-asset.txt`); + const shadowedByAssetHeader = page.getByRole("heading", { + name: "404 page!", + }); + await shadowedByAssetHeader.waitFor({ state: "attached" }); + + const shadowedBySpaLink = page + .getByText("/shadowed-by-spa HARD SOFT") + .getByText("SOFT"); + expect(await shadowedBySpaLink.getAttribute("href")).toBe( + "/shadowed-by-spa" + ); + await shadowedBySpaLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/shadowed-by-spa`); + const shadowedBySpaHeader = page.getByRole("heading", { + name: "Shadowed by SPA!", + }); + await shadowedBySpaHeader.waitFor({ state: "attached" }); + + const mathLink = page.getByText("/api/math HARD SOFT").getByText("SOFT"); + expect(await mathLink.getAttribute("href")).toBe("/api/math"); + await mathLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/api/math`); + const mathHeader = page.getByRole("heading", { + name: "404 page!", + }); + await mathHeader.waitFor({ state: "attached" }); + }); + + it("navigates hard navigations correctly", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/"); + + const homepageLink = page.getByText("/ HARD SOFT").getByText("HARD"); + expect(await homepageLink.getAttribute("href")).toBe("/"); + await homepageLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/`); + const homepageHeader = page.getByRole("heading", { name: "Homepage" }); + await homepageHeader.waitFor({ state: "attached" }); + + const blogLink = page.getByText("/blog HARD SOFT").getByText("HARD"); + expect(await blogLink.getAttribute("href")).toBe("/blog"); + await blogLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/blog`); + const blogTitleLocator = page.getByRole("heading", { name: "Blog" }); + await blogTitleLocator.waitFor({ state: "attached" }); + + expect( + getOutput().match( + /GET \/blog 200 OK \(.*\) `Sec-Fetch-Mode: navigate` header present - using `not_found_handling` behavior/ + ) + ).toBeTruthy(); + + const blogSlugInput = page.getByRole("textbox"); + blogSlugInput.fill("/blog/some-slug-here"); + + const blogSlugLink = page.getByRole("link", { name: "HARD load" }); + await blogSlugLink.click(); + expect(page.url()).toBe(`http://${ip}:${port}/blog/some-slug-here`); + const blogSlugTitleLocator = page.getByRole("heading", { + name: "Blog | some-slug-here", + }); + await blogSlugTitleLocator.waitFor({ state: "attached" }); + + const blogRandomLink = page + .getByText("/blog/random HARD SOFT") + .getByText("HARD"); + expect(await blogRandomLink.getAttribute("href")).toBe("/blog/random"); + await blogRandomLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/blog/random`); + const blogRandomTitleLocator = page.getByRole("heading", { + name: "Blog | random", + }); + await blogRandomTitleLocator.waitFor({ state: "attached" }); + + const shadowedByAssetLink = page + .getByText("/shadowed-by-asset.txt HARD SOFT") + .getByText("HARD"); + expect(await shadowedByAssetLink.getAttribute("href")).toBe( + "/shadowed-by-asset.txt" + ); + await shadowedByAssetLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/shadowed-by-asset.txt`); + expect(await page.content()).toContain("i'm some text!"); + + await page.goBack(); + + const shadowedBySpaLink = page + .getByText("/shadowed-by-spa HARD SOFT") + .getByText("HARD"); + expect(await shadowedBySpaLink.getAttribute("href")).toBe( + "/shadowed-by-spa" + ); + await shadowedBySpaLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/shadowed-by-spa`); + const shadowedBySpaHeader = page.getByRole("heading", { + name: "Shadowed by SPA!", + }); + await shadowedBySpaHeader.waitFor({ state: "attached" }); + + const mathLink = page.getByText("/api/math HARD SOFT").getByText("HARD"); + expect(await mathLink.getAttribute("href")).toBe("/api/math"); + await mathLink.click(); + + expect(page.url()).toBe(`http://${ip}:${port}/api/math`); + const mathHeader = page.getByRole("heading", { + name: "404 page!", + }); + await mathHeader.waitFor({ state: "attached" }); + }); + + it("direct fetches don't look like SPA requests", async ({ expect }) => { + const homepageResponse = await fetch(`http://${ip}:${port}/`); + expect(await homepageResponse.text()).toContain("Homepage"); + + const blogResponse = await fetch(`http://${ip}:${port}/blog`); + expect(await blogResponse.text()).toBe("nope"); + + const shadowedByAssetResponse = await fetch( + `http://${ip}:${port}/shadowed-by-asset.txt` + ); + expect(await shadowedByAssetResponse.text()).toBe("i'm some text!"); + + const mathResponse = await fetch(`http://${ip}:${port}/api/math`); + expect(await mathResponse.text()).toBe("1 + 1 = 2"); + }); +}); diff --git a/fixtures/workers-with-assets-spa/tests/tsconfig.json b/fixtures/workers-with-assets-spa/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/workers-with-assets-spa/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/workers-with-assets-spa/tsconfig.json b/fixtures/workers-with-assets-spa/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/workers-with-assets-spa/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/workers-with-assets-spa/vitest.config.mts b/fixtures/workers-with-assets-spa/vitest.config.mts new file mode 100644 index 0000000..472d8fe --- /dev/null +++ b/fixtures/workers-with-assets-spa/vitest.config.mts @@ -0,0 +1,14 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + // The `runWranglerDev` helper will wait up to 50 secs for Wrangler to boot up + // The `chromium.launch` helper will wait up to 30 secs for the browser to boot up. + hookTimeout: 50_000, + testTimeout: 50_000, + }, + }) +); diff --git a/fixtures/workers-with-assets-spa/wrangler.jsonc b/fixtures/workers-with-assets-spa/wrangler.jsonc new file mode 100644 index 0000000..d949bcb --- /dev/null +++ b/fixtures/workers-with-assets-spa/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "worker-with-assets-spa", + "main": "./src/index.ts", + "compatibility_date": "2025-03-11", + "compatibility_flags": ["assets_navigation_prefers_asset_serving"], + "assets": { + "directory": "./public/", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "run_worker_first": false, + }, + "routes": ["example.com"], +} diff --git a/fixtures/workers-with-assets-static-routing/package.json b/fixtures/workers-with-assets-static-routing/package.json new file mode 100644 index 0000000..965c116 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/package.json @@ -0,0 +1,23 @@ +{ + "name": "@fixture/workers-with-assets-static-routing", + "private": true, + "scripts": { + "check:type": "tsc", + "dev": "wrangler dev", + "dev:spa": "wrangler dev -c spa.wrangler.jsonc", + "pretest:ci": "pnpm playwright install chromium", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./test/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@types/node": "catalog:default", + "playwright-chromium": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/workers-with-assets-static-routing/public/static/page.html b/fixtures/workers-with-assets-static-routing/public/static/page.html new file mode 100644 index 0000000..c61edeb --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/public/static/page.html @@ -0,0 +1 @@ +

A normal asset

diff --git a/fixtures/workers-with-assets-static-routing/public/worker/asset.html b/fixtures/workers-with-assets-static-routing/public/worker/asset.html new file mode 100644 index 0000000..1052ed1 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/public/worker/asset.html @@ -0,0 +1 @@ +

Hello, I'm an asset!

diff --git a/fixtures/workers-with-assets-static-routing/public/worker/worker-runs.html b/fixtures/workers-with-assets-static-routing/public/worker/worker-runs.html new file mode 100644 index 0000000..57c660b --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/public/worker/worker-runs.html @@ -0,0 +1 @@ +

Hello, I'm an asset at /worker/worker-runs.html!

diff --git a/fixtures/workers-with-assets-static-routing/spa-assets/index.html b/fixtures/workers-with-assets-static-routing/spa-assets/index.html new file mode 100644 index 0000000..b696b5e --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/spa-assets/index.html @@ -0,0 +1,14 @@ + + + + I'm an index.html for a SPA + + +

Here I am, at /!

+ + + diff --git a/fixtures/workers-with-assets-static-routing/spa.wrangler.jsonc b/fixtures/workers-with-assets-static-routing/spa.wrangler.jsonc new file mode 100644 index 0000000..f1472a2 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/spa.wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "name": "workers-with-assets-static-routing", + "main": "src/index.ts", + "compatibility_date": "2025-05-20", + "assets": { + "binding": "ASSETS", + "directory": "./spa-assets", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*", "!/api/asset"], + }, +} diff --git a/fixtures/workers-with-assets-static-routing/src/index.ts b/fixtures/workers-with-assets-static-routing/src/index.ts new file mode 100644 index 0000000..5580077 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/src/index.ts @@ -0,0 +1,31 @@ +export type Env = { + ASSETS: Fetcher; +}; + +export default { + async fetch(request, env, ctx): Promise { + const { pathname } = new URL(request.url); + + // api routes + if (pathname.startsWith("/api/")) { + return Response.json({ some: ["json", "response"] }); + } + + // asset middleware + const assetResp = await env.ASSETS.fetch(request); + if (assetResp.ok) { + let text = await assetResp.text(); + text = text.replace( + "I'm an asset", + "I'm an asset (and was intercepted by the User Worker)" + ); + return new Response(text, { + headers: assetResp.headers, + status: assetResp.status, + }); + } + + // default handling + return new Response("404 from the User Worker", { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/fixtures/workers-with-assets-static-routing/test/index.test.ts b/fixtures/workers-with-assets-static-routing/test/index.test.ts new file mode 100644 index 0000000..91a445c --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/test/index.test.ts @@ -0,0 +1,177 @@ +import { resolve } from "node:path"; +import { Browser, chromium } from "playwright-chromium"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("[Workers + Assets] static routing", () => { + describe("static routing behavior", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should serve assets when they exist for a path", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/static/page`); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`

A normal asset

`); + }); + + it("should run the worker when no assets exist for a path", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/`); + expect(response.status).toBe(404); + expect(await response.text()).toContain(`404 from the User Worker`); + }); + + it("should run the worker when a positive run_worker_first rule matches", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/worker/worker-runs`); + expect(response.status).toBe(200); + expect(await response.text()).toContain( + `

Hello, I'm an asset (and was intercepted by the User Worker) at /worker/worker-runs.html!

` + ); + }); + + it("should serve a 404 when a negative run_worker_first rule matches", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/missing-asset`); + expect(response.status).toBe(404); + expect(await response.text()).toEqual(""); + }); + + it("should serve an asset when both a positive and negative (asset) run_worker_first matches", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/worker/asset`); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`

Hello, I'm an asset!

`); + }); + }); + + describe("static routing + SPA behavior", async () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "-c=spa.wrangler.jsonc", + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + describe("browser navigation", () => { + let browser: Browser | undefined; + + beforeAll(async () => { + browser = await chromium.launch({ + headless: !process.env.VITE_DEBUG_SERVE, + args: process.env.CI + ? ["--no-sandbox", "--disable-setuid-sandbox"] + : undefined, + }); + }, 40_000); + + it("renders the root with index.html", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/"); + expect(await page.getByRole("heading").innerText()).toBe( + "Here I am, at /!" + ); + }); + + it("renders another path with index.html", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/some/page"); + expect(await page.getByRole("heading").innerText()).toBe( + "Here I am, at /some/page!" + ); + }); + + it("renders an include path with the User worker", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + const response = await page.goto("/api/route"); + expect(response?.headers()).toHaveProperty( + "content-type", + "application/json" + ); + expect(await page.content()).toContain(`{"some":["json","response"]}`); + }); + + it("renders an exclude path with index.html", async ({ expect }) => { + if (!browser) { + throw new Error("Browser couldn't be initialized"); + } + + const page = await browser.newPage({ + baseURL: `http://${ip}:${port}`, + }); + await page.goto("/api/asset"); + expect(await page.getByRole("heading").innerText()).toBe( + "Here I am, at /api/asset!" + ); + }); + }); + + describe("non-browser navigation", () => { + it("renders the root with index.html", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}`); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`I'm an index.html for a SPA`); + }); + + it("renders another path with index.html", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/some/page`); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`I'm an index.html for a SPA`); + }); + + it("renders an include path with the User worker", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/api/route`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toEqual( + "application/json" + ); + expect(await response.text()).toContain(`{"some":["json","response"]}`); + }); + + it("renders an exclude path with index.html", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/api/asset`); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`I'm an index.html for a SPA`); + }); + }); + }); +}); diff --git a/fixtures/workers-with-assets-static-routing/test/tsconfig.json b/fixtures/workers-with-assets-static-routing/test/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/workers-with-assets-static-routing/tsconfig.json b/fixtures/workers-with-assets-static-routing/tsconfig.json new file mode 100644 index 0000000..6ec3531 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["test"] +} diff --git a/fixtures/workers-with-assets-static-routing/vitest.config.mts b/fixtures/workers-with-assets-static-routing/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/workers-with-assets-static-routing/wrangler.jsonc b/fixtures/workers-with-assets-static-routing/wrangler.jsonc new file mode 100644 index 0000000..cc23155 --- /dev/null +++ b/fixtures/workers-with-assets-static-routing/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "name": "workers-with-assets-static-routing", + "main": "src/index.ts", + "compatibility_date": "2025-05-20", + "assets": { + "binding": "ASSETS", + "directory": "./public", + "run_worker_first": [ + // The `/oauth/callback` path and anything under `/worker` will be served by the Worker first + "/worker/*", + "/oauth/callback", + // The `/missing-asset` and `worker/asset` paths will not be served by the Worker first + "!/missing-asset", + "!/worker/asset", + ], + }, +} diff --git a/fixtures/workers-with-assets/.gitignore b/fixtures/workers-with-assets/.gitignore new file mode 100644 index 0000000..36c1562 --- /dev/null +++ b/fixtures/workers-with-assets/.gitignore @@ -0,0 +1,2 @@ +./public/.assetsignore +./public/_redirects diff --git a/fixtures/workers-with-assets/README.md b/fixtures/workers-with-assets/README.md new file mode 100644 index 0000000..4a0d8f0 --- /dev/null +++ b/fixtures/workers-with-assets/README.md @@ -0,0 +1,17 @@ +# workers-assets-with-user-worker + +`workers-assets-with-user-worker` is a test fixture that showcases Workers with Assets. This particular fixture sets up a User Worker, assets, and a binding from the user Worker to the assets. + +## dev + +To start a dev session you can run + +``` +wrangler dev +``` + +## Run tests + +``` +npm run test +``` diff --git a/fixtures/workers-with-assets/package.json b/fixtures/workers-with-assets/package.json new file mode 100644 index 0000000..b675ff5 --- /dev/null +++ b/fixtures/workers-with-assets/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/workers-assets", + "private": true, + "scripts": { + "check:type": "tsc", + "dev": "wrangler dev", + "test:ci": "vitest run", + "test:watch": "vitest", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/workers-with-assets/public/.assetsignore b/fixtures/workers-with-assets/public/.assetsignore new file mode 100644 index 0000000..554fc76 --- /dev/null +++ b/fixtures/workers-with-assets/public/.assetsignore @@ -0,0 +1 @@ +ignore-me.txt diff --git a/fixtures/workers-with-assets/public/.dot/index.html b/fixtures/workers-with-assets/public/.dot/index.html new file mode 100644 index 0000000..15f2047 --- /dev/null +++ b/fixtures/workers-with-assets/public/.dot/index.html @@ -0,0 +1 @@ +hi from .dot/index.html diff --git a/fixtures/workers-with-assets/public/.dotfile.html b/fixtures/workers-with-assets/public/.dotfile.html new file mode 100644 index 0000000..23ee20b --- /dev/null +++ b/fixtures/workers-with-assets/public/.dotfile.html @@ -0,0 +1 @@ +hi from .dotfile.html diff --git a/fixtures/workers-with-assets/public/README.md b/fixtures/workers-with-assets/public/README.md new file mode 100644 index 0000000..a78f2f0 --- /dev/null +++ b/fixtures/workers-with-assets/public/README.md @@ -0,0 +1,57 @@ +# Workers with Assets + +Welcome to Workers + Assets YAY! + +Please proceed with much excitement ^.^ + + ................. + .......-++**********:. + ..-+****++**********#: + ...-+***+++====+********##= + ....:=****+======+***********##+ + ....-****+======+*************###+ + .:==+*+======+***************####= + ....-===--=====++****************#####: + ....:====-:::-==+******************#####+. + ..-====:::::::+*******************#####*.. + ..-===-:::::::-==+*****************######=.. + ..-===-::::::-=======***************######*. + ...-===-::::::-==========+************######*:. + ...-===-::::::-==----========+*********#######:.. + .-===-::::::--::::::::::-======+******#######-. . + ..:====::::::-=:::-*####*-:::-=======+**#######-. + .:====-:::::-==:::*#########-::========+**#####- + ..====-:::::-===-::###########*:::======*******+.. + ..=+====:::::=====::-############:::====+*******=... + ....:=++====:::-======-::###########*:::===+*******-.. + ...........:-==+**+=++++=============-:::*#########-::===********:.. + ....--=+************====+++===============:::=*####*=:::-=+*******=.. + .:*************###*=====++++================::::::::::-==********:.. + ..*****#############=======++++==========================+*******+... + ...+****#############*==:::-===++++========================********-. + ...+****###############+=::::=====+++++====================********+... + .=****################*=-:::-======+++++=================+********:.. + ..-****##################===:-=========++++++=============+********:.. + .+***####################===========+***#++++++=========+********=.. + .*###################+-.-=======+****##*==+++++++====+********=... + ...-+#############*=...-++=====+****####+=====++++++*********+... + ....-*######+:.....++++===******####==========+*######**+.. + ...+=.....:::-+++++******#####=========+***######*.. + .......::::::=++******#####+========***********:... + ....::::::...+*****######=======+**********##*. + ..:::::....-*****######+=====+**********#####*. + ...:::::...:******#####*+++++*********#########+. + ..:::::...+*****######+++++++=.=###############:. + .:::::..-*****######-..-=++=..-##########***###.. + ....::::..=****######=....::::..:########******##*.. + ...::::..+***######-.....::::...*##***********###=.. + ..::::...-**####*:......::::...###************###-.. + ..:::......=##*:......:::::...+##*************###: + ...::::...............:::::....+##*************####. + ...::::::::::::::..:::::::......**************###-.. + .::::::::::::::::::::::..... ...:***********###+.... + ..::::.......::::::...... ..:********###*:... + ........ ...=******###=.. + .=***###+.. + ..*####:... + ........ diff --git a/fixtures/workers-with-assets/public/_headers b/fixtures/workers-with-assets/public/_headers new file mode 100644 index 0000000..572e15d --- /dev/null +++ b/fixtures/workers-with-assets/public/_headers @@ -0,0 +1,2 @@ +/ + X-Header: Custom-Value \ No newline at end of file diff --git a/fixtures/workers-with-assets/public/_redirects b/fixtures/workers-with-assets/public/_redirects new file mode 100644 index 0000000..947c638 --- /dev/null +++ b/fixtures/workers-with-assets/public/_redirects @@ -0,0 +1,2 @@ +/foo /bar +/pic /lava-lamps.jpg 200 diff --git a/fixtures/workers-with-assets/public/_worker.js b/fixtures/workers-with-assets/public/_worker.js new file mode 100644 index 0000000..7b5c6d1 --- /dev/null +++ b/fixtures/workers-with-assets/public/_worker.js @@ -0,0 +1 @@ +bang; diff --git a/fixtures/workers-with-assets/public/about/%5Bboop%5D.html b/fixtures/workers-with-assets/public/about/%5Bboop%5D.html new file mode 100644 index 0000000..7a0e369 --- /dev/null +++ b/fixtures/workers-with-assets/public/about/%5Bboop%5D.html @@ -0,0 +1 @@ +

%5Bboop%5D.html

diff --git a/fixtures/workers-with-assets/public/about/%5Bwomp%5D.html b/fixtures/workers-with-assets/public/about/%5Bwomp%5D.html new file mode 100644 index 0000000..de76167 --- /dev/null +++ b/fixtures/workers-with-assets/public/about/%5Bwomp%5D.html @@ -0,0 +1 @@ +

womp

diff --git a/fixtures/workers-with-assets/public/about/[boop].html b/fixtures/workers-with-assets/public/about/[boop].html new file mode 100644 index 0000000..37af005 --- /dev/null +++ b/fixtures/workers-with-assets/public/about/[boop].html @@ -0,0 +1 @@ +

[boop].html

diff --git a/fixtures/workers-with-assets/public/about/[fünky].txt b/fixtures/workers-with-assets/public/about/[fünky].txt new file mode 100644 index 0000000..dbb8484 --- /dev/null +++ b/fixtures/workers-with-assets/public/about/[fünky].txt @@ -0,0 +1 @@ +This should work. \ No newline at end of file diff --git a/fixtures/workers-with-assets/public/about/index.html b/fixtures/workers-with-assets/public/about/index.html new file mode 100644 index 0000000..14f9dff --- /dev/null +++ b/fixtures/workers-with-assets/public/about/index.html @@ -0,0 +1 @@ +

Learn more about Workers with Assets soon!

diff --git a/fixtures/workers-with-assets/public/bar.html b/fixtures/workers-with-assets/public/bar.html new file mode 100644 index 0000000..5e46a91 --- /dev/null +++ b/fixtures/workers-with-assets/public/bar.html @@ -0,0 +1 @@ +

Bar

diff --git a/fixtures/workers-with-assets/public/binding.html b/fixtures/workers-with-assets/public/binding.html new file mode 100644 index 0000000..8edcac2 --- /dev/null +++ b/fixtures/workers-with-assets/public/binding.html @@ -0,0 +1 @@ +

✨This is from a user Worker binding✨

diff --git a/fixtures/workers-with-assets/public/foo.html b/fixtures/workers-with-assets/public/foo.html new file mode 100644 index 0000000..88dadda --- /dev/null +++ b/fixtures/workers-with-assets/public/foo.html @@ -0,0 +1 @@ +

Foo

diff --git a/fixtures/workers-with-assets/public/ignore-me.txt b/fixtures/workers-with-assets/public/ignore-me.txt new file mode 100644 index 0000000..4e2646a --- /dev/null +++ b/fixtures/workers-with-assets/public/ignore-me.txt @@ -0,0 +1 @@ +THIS IS A SECRET \ No newline at end of file diff --git a/fixtures/workers-with-assets/public/index.html b/fixtures/workers-with-assets/public/index.html new file mode 100644 index 0000000..b7351e6 --- /dev/null +++ b/fixtures/workers-with-assets/public/index.html @@ -0,0 +1 @@ +

Hello Workers + Assets World 🚀!

diff --git a/fixtures/workers-with-assets/public/lava-lamps.jpg b/fixtures/workers-with-assets/public/lava-lamps.jpg new file mode 100644 index 0000000..fc66cad Binary files /dev/null and b/fixtures/workers-with-assets/public/lava-lamps.jpg differ diff --git a/fixtures/workers-with-assets/public/totallyinvalidextension.greg b/fixtures/workers-with-assets/public/totallyinvalidextension.greg new file mode 100644 index 0000000..0c0f38d --- /dev/null +++ b/fixtures/workers-with-assets/public/totallyinvalidextension.greg @@ -0,0 +1 @@ +I'm a narcissist. \ No newline at end of file diff --git a/fixtures/workers-with-assets/public/yay.txt b/fixtures/workers-with-assets/public/yay.txt new file mode 100644 index 0000000..b84f6bc --- /dev/null +++ b/fixtures/workers-with-assets/public/yay.txt @@ -0,0 +1,12 @@ + + .----------------. .----------------. .----------------. +| .--------------. || .--------------. || .--------------. | +| | ____ ____ | || | __ | || | ____ ____ | | +| | |_ _||_ _| | || | / \ | || | |_ _||_ _| | | +| | \ \ / / | || | / /\ \ | || | \ \ / / | | +| | \ \/ / | || | / ____ \ | || | \ \/ / | | +| | _| |_ | || | _/ / \ \_ | || | _| |_ | | +| | |______| | || ||____| |____|| || | |______| | | +| | | || | | || | | | +| '--------------' || '--------------' || '--------------' | + '----------------' '----------------' '----------------' diff --git a/fixtures/workers-with-assets/src/index.ts b/fixtures/workers-with-assets/src/index.ts new file mode 100644 index 0000000..a1ff321 --- /dev/null +++ b/fixtures/workers-with-assets/src/index.ts @@ -0,0 +1,32 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +export interface Env { + // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ + ASSETS: Fetcher; + NAMED: Fetcher; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/assets-binding") { + return await env.ASSETS.fetch(new URL("binding.html", request.url)); + } + + if (url.pathname === "/named-entrypoint") { + const res = await env.NAMED.sayHello(); + return new Response(res); + } + // 404s from the Asset Worker will return this: + return new Response( + "There were no assets at this route! Hello from the user Worker instead!" + + `\n${new Date()}` + ); + }, +}; + +export class NamedEntrypoint extends WorkerEntrypoint { + async sayHello() { + return "hello from a named entrypoint"; + } +} diff --git a/fixtures/workers-with-assets/tests/index.test.ts b/fixtures/workers-with-assets/tests/index.test.ts new file mode 100644 index 0000000..ff2b68f --- /dev/null +++ b/fixtures/workers-with-assets/tests/index.test.ts @@ -0,0 +1,299 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it, onTestFinished } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("[Workers + Assets] dynamic site", () => { + let ip: string, port: number, stop: (() => Promise) | undefined; + + beforeAll(async () => { + ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ + "--port=0", + "--inspector-port=0", + ])); + }); + + afterAll(async () => { + await stop?.(); + }); + + it("should respond with static asset content", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/index.html`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain(`

Hello Workers + Assets World 🚀!

`); + + response = await fetch(`http://${ip}:${port}/about/index.html`); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain(`

Learn more about Workers with Assets soon!

`); + }); + + it("should fallback to the user Worker if there are no assets at a given path ", async ({ + expect, + }) => { + // Requests should hit the Asset Worker *first*, then try the user Worker + const response = await fetch(`http://${ip}:${port}/no-assets-here`); + const text = await response.text(); + expect(text).toContain( + "There were no assets at this route! Hello from the user Worker instead!" + ); + }); + + // html_handling defaults to 'auto-trailing-slash' + it("should `/` resolve to `/index.html` ", async ({ expect }) => { + const response = await fetch(`http://${ip}:${port}/`); + const text = await response.text(); + expect(text).toContain("

Hello Workers + Assets World 🚀!

"); + }); + + it("should handle content types correctly on asset routes", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/index.html`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8" + ); + + response = await fetch(`http://${ip}:${port}/README.md`); + text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/markdown; charset=utf-8" + ); + expect(text).toContain(`Welcome to Workers + Assets YAY!`); + + response = await fetch(`http://${ip}:${port}/yay.txt`); + text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/plain; charset=utf-8" + ); + expect(text).toContain(`.----------------.`); + + response = await fetch(`http://${ip}:${port}/lava-lamps.jpg`); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + + response = await fetch(`http://${ip}:${port}/totallyinvalidextension.greg`); + expect(response.status).toBe(200); + expect(response.headers.has("Content-Type")).toBeFalsy(); + }); + + it("should return 405 for non-GET or HEAD requests on routes where assets exist", async ({ + expect, + }) => { + // these should return the error and NOT be forwarded onto the user Worker + // POST etc. request -> RW -> AW -> check manifest --405--> RW --405--> eyeball + + // methods as per https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods + // excl. TRACE and CONNECT which are not supported + + let response = await fetch(`http://${ip}:${port}/index.html`, { + method: "POST", + }); + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + + response = await fetch(`http://${ip}:${port}/index.html`, { + method: "PUT", + }); + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + + response = await fetch(`http://${ip}:${port}/index.html`, { + method: "DELETE", + }); + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + + response = await fetch(`http://${ip}:${port}/index.html`, { + method: "OPTIONS", + }); + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + + response = await fetch(`http://${ip}:${port}/index.html`, { + method: "PATCH", + }); + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + }); + + it("should work with encoded path names", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/about/[fünky].txt`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(response.url).toBe( + `http://${ip}:${port}/about/%5Bf%C3%BCnky%5D.txt` + ); + expect(text).toContain(`This should work.`); + + response = await fetch(`http://${ip}:${port}/about/[boop]`); + text = await response.text(); + expect(response.status).toBe(200); + expect(response.url).toBe(`http://${ip}:${port}/about/%5Bboop%5D`); + expect(text).toContain(`[boop].html`); + + response = await fetch(`http://${ip}:${port}/about/%5Bboop%5D`); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain(`[boop].html`); + + response = await fetch(`http://${ip}:${port}/about/%255Bboop%255D`); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain(`%5Bboop%5D.html`); + }); + + it("should forward all request types to the user Worker if there are *not* assets on that route", async ({ + expect, + }) => { + // Unlike above, if the AW does NOT find assets on a route, non-GET request should return 404s + // This is because all requests are first sent to the AW and only then to the UW + // and we don't want to 405 on a valid POST request intended for the UW + // POST etc. request -> RW -> AW -> checks manifest --404--> RW -> UW -> response + + let response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "GET", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "HEAD", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "POST", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "PUT", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "DELETE", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "OPTIONS", + }); + expect(response.status).toBe(200); + + response = await fetch(`http://${ip}:${port}/no-assets-here`, { + method: "PATCH", + }); + expect(response.status).toBe(200); + }); + + it("should be able to use an ASSETS binding", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/assets-binding`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8" + ); + expect(text).toContain("

✨This is from a user Worker binding✨

"); + }); + + it("should be able to use a binding to a named entrypoint", async ({ + expect, + }) => { + let response = await fetch(`http://${ip}:${port}/named-entrypoint`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain("hello from a named entrypoint"); + }); + + it("should apply custom redirects", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/foo`, { + redirect: "manual", + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/bar"); + + response = await fetch(`http://${ip}:${port}/pic`); + expect(response.status).toBe(200); + expect(await response.arrayBuffer()).toEqual( + readFileSync(join(__dirname, "../public/lava-lamps.jpg")).buffer + ); + }); + + it("should apply custom headers", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/`); + expect(response.status).toBe(200); + expect(response.headers.get("X-Header")).toBe("Custom-Value"); + }); + + it("should apply .assetsignore", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/.assetsignore`); + expect(await response.text()).not.toContain("ignore-me.txt"); + + response = await fetch(`http://${ip}:${port}/ignore-me.txt`); + expect(await response.text()).not.toContain("SECRET"); + + response = await fetch(`http://${ip}:${port}/_headers`); + expect(await response.text()).not.toContain("X-Header"); + }); + + it.todo("should warn of _worker.js", async () => { + // let response = await fetch(`http://${ip}:${port}/_worker.js`); + // expect(await response.text()).not.toContain("bang"); + }); + + it("should work with files which start with .", async ({ expect }) => { + let response = await fetch(`http://${ip}:${port}/.dot`); + let text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(` + "hi from .dot/index.html + " + `); + + response = await fetch(`http://${ip}:${port}/.dotfile.html`); + text = await response.text(); + expect(response.status).toBe(200); + expect(text).toMatchInlineSnapshot(` + "hi from .dotfile.html + " + `); + }); +}); + +describe("[Workers + Assets] logging", () => { + it("should log _headers and _redirects parsing", async ({ expect }) => { + const { ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + ); + + // Await a request to ensure the logs have been written + await fetch(`http://${ip}:${port}/`); + + expect(getOutput()).toContain( + `[wrangler:info] ✨ Parsed 2 valid redirect rules.` + ); + expect(getOutput()).toContain( + `[wrangler:info] ✨ Parsed 1 valid header rule.` + ); + onTestFinished(() => stop()); + }); + + it("should not log _headers and _redirects parsing when log level set to none", async ({ + expect, + }) => { + const { ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0", "--log-level=none"] + ); + expect(getOutput()).toMatchInlineSnapshot(`""`); + onTestFinished(() => stop()); + }); +}); diff --git a/fixtures/workers-with-assets/tests/tsconfig.json b/fixtures/workers-with-assets/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/workers-with-assets/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/workers-with-assets/tsconfig.json b/fixtures/workers-with-assets/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/workers-with-assets/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/workers-with-assets/vitest.config.mts b/fixtures/workers-with-assets/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/workers-with-assets/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/workers-with-assets/wrangler.jsonc b/fixtures/workers-with-assets/wrangler.jsonc new file mode 100644 index 0000000..eca8be6 --- /dev/null +++ b/fixtures/workers-with-assets/wrangler.jsonc @@ -0,0 +1,17 @@ +{ + "name": "worker-with-assets", + "main": "src/index.ts", + "compatibility_date": "2024-12-12", + "assets": { + "directory": "./public", + "binding": "ASSETS", + "run_worker_first": false, + }, + "services": [ + { + "binding": "NAMED", + "service": "worker-with-assets", + "entrypoint": "NamedEntrypoint", + }, + ], +} diff --git a/fixtures/workflow-multiple/package.json b/fixtures/workflow-multiple/package.json new file mode 100644 index 0000000..2d7d6e5 --- /dev/null +++ b/fixtures/workflow-multiple/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fixture/workflow-multiple", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/workflow-multiple/src/index.ts b/fixtures/workflow-multiple/src/index.ts new file mode 100644 index 0000000..083ed12 --- /dev/null +++ b/fixtures/workflow-multiple/src/index.ts @@ -0,0 +1,139 @@ +import { + WorkerEntrypoint, + WorkflowEntrypoint, + WorkflowEvent, + WorkflowStep, +} from "cloudflare:workers"; + +type Params = { + name: string; +}; + +export class Demo extends WorkflowEntrypoint<{}, Params> { + async run(event: WorkflowEvent, step: WorkflowStep) { + const { timestamp, payload } = event; + + await step.sleep("Wait", "1 second"); + + const result = await step.do("First step", async function () { + return { + output: "First step result", + }; + }); + + await step.sleep("Wait", "1 second"); + + const result2 = await step.do("Second step", async function () { + return { + output: "workflow1", + }; + }); + + return "i'm workflow1"; + } +} + +export class Demo2 extends WorkflowEntrypoint<{}, Params> { + async run(event: WorkflowEvent, step: WorkflowStep) { + const { timestamp, payload } = event; + + await step.sleep("Wait", "1 second"); + + const result = await step.do("First step", async function () { + return { + output: "First step result", + }; + }); + + await step.sleep("Wait", "1 second"); + + const result2 = await step.do("Second step", async function () { + return { + output: "workflow2", + }; + }); + + return "i'm workflow2"; + } +} + +export class Demo3 extends WorkflowEntrypoint<{}, Params> { + async run(event: WorkflowEvent, step: WorkflowStep) { + const result = await step.do("First step", async function () { + return { + output: "First step result", + }; + }); + + await step.waitForEvent("wait for signal", { + type: "continue", + }); + + const result2 = await step.do("Second step", async function () { + return { + output: "workflow3", + }; + }); + + return "i'm workflow3"; + } +} + +type Env = { + WORKFLOW: Workflow; + WORKFLOW2: Workflow; + WORKFLOW3: Workflow; +}; + +export default class extends WorkerEntrypoint { + async fetch(req: Request) { + const url = new URL(req.url); + const id = url.searchParams.get("id"); + const workflowName = url.searchParams.get("workflowName"); + + if (url.pathname === "/favicon.ico") { + return new Response(null, { status: 404 }); + } + + let workflowToUse: Workflow; + if (workflowName === "3") { + workflowToUse = this.env.WORKFLOW3; + } else if (workflowName === "2") { + workflowToUse = this.env.WORKFLOW2; + } else { + workflowToUse = this.env.WORKFLOW; + } + + let handle: WorkflowInstance; + if (url.pathname === "/create") { + if (id === null) { + handle = await workflowToUse.create(); + } else { + handle = await workflowToUse.create({ id }); + } + } else if (url.pathname === "/pause") { + handle = await workflowToUse.get(id); + await handle.pause(); + } else if (url.pathname === "/resume") { + handle = await workflowToUse.get(id); + await handle.resume(); + } else if (url.pathname === "/restart") { + handle = await workflowToUse.get(id); + await handle.restart(); + } else if (url.pathname === "/terminate") { + handle = await workflowToUse.get(id); + await handle.terminate(); + } else if (url.pathname === "/sendEvent") { + handle = await workflowToUse.get(id); + await handle.sendEvent({ + type: "continue", + payload: await req.json(), + }); + return Response.json({ ok: true }); + } else { + handle = await workflowToUse.get(id); + } + + return Response.json({ status: await handle.status(), id: handle.id }); + } +} diff --git a/fixtures/workflow-multiple/tests/index.test.ts b/fixtures/workflow-multiple/tests/index.test.ts new file mode 100644 index 0000000..410d17d --- /dev/null +++ b/fixtures/workflow-multiple/tests/index.test.ts @@ -0,0 +1,244 @@ +import { randomUUID } from "crypto"; +import { rm } from "fs/promises"; +import { resolve } from "path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it, test, vi } from "vitest"; +import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; + +describe("Workflows", () => { + let ip: string, + port: number, + stop: (() => Promise) | undefined, + getOutput: () => string; + + beforeAll(async () => { + // delete previous run contents because of persistence + await rm(resolve(__dirname, "..") + "/.wrangler", { + force: true, + recursive: true, + }); + ({ ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + async function fetchJson(url: string, body?: unknown, method?: string) { + const response = await fetch(url, { + headers: { + "MF-Disable-Pretty-Error": "1", + }, + method: method ?? "GET", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`Couldn't parse JSON:\n\n${text}`); + } + } + + it("creates two instances with same id in two different workflows", async ({ + expect, + }) => { + // Create both workflow instances + // Note: We don't assert the intermediate "running" status because the workflow + // may complete before we can observe it, causing flaky tests on fast CI machines + await Promise.all([ + fetchJson(`http://${ip}:${port}/create?workflowName=1&id=test`), + fetchJson(`http://${ip}:${port}/create?workflowName=2&id=test`), + ]); + + // Wait for both workflows to complete with their final outputs + await Promise.all([ + vi.waitFor( + async () => { + await expect( + fetchJson(`http://${ip}:${port}/status?workflowName=1&id=test`) + ).resolves.toStrictEqual({ + id: "test", + status: { + status: "complete", + __LOCAL_DEV_STEP_OUTPUTS: [ + { output: "First step result" }, + { output: "workflow1" }, + ], + output: "i'm workflow1", + }, + }); + }, + { timeout: 10000 } + ), + vi.waitFor( + async () => { + await expect( + fetchJson(`http://${ip}:${port}/status?workflowName=2&id=test`) + ).resolves.toStrictEqual({ + id: "test", + status: { + status: "complete", + __LOCAL_DEV_STEP_OUTPUTS: [ + { output: "First step result" }, + { output: "workflow2" }, + ], + output: "i'm workflow2", + }, + }); + }, + { timeout: 10000 } + ), + ]); + }); + + describe("instance lifecycle methods (workflow3)", () => { + test("pause and resume a workflow", async ({ expect }) => { + const id = randomUUID(); + + await fetchJson(`http://${ip}:${port}/create?workflowName=3&id=${id}`); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { + status: { + __LOCAL_DEV_STEP_OUTPUTS: { output: string }[]; + }; + }; + expect(result.status.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Pause the instance + await fetchJson(`http://${ip}:${port}/pause?workflowName=3&id=${id}`); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { status: { status: string } }; + expect(result.status.status).toBe("paused"); + }, + { timeout: 5000 } + ); + + // Resume the instance + await fetchJson(`http://${ip}:${port}/resume?workflowName=3&id=${id}`); + + await fetchJson( + `http://${ip}:${port}/sendEvent?workflowName=3&id=${id}`, + { done: true }, + "POST" + ); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { status: { status: string; output: string } }; + expect(result.status.status).toBe("complete"); + expect(result.status.output).toBe("i'm workflow3"); + }, + { timeout: 5000 } + ); + }); + + test("terminate a running workflow", async ({ expect }) => { + const id = randomUUID(); + + await fetchJson(`http://${ip}:${port}/create?workflowName=3&id=${id}`); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { + status: { + __LOCAL_DEV_STEP_OUTPUTS: { output: string }[]; + }; + }; + expect(result.status.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Terminate + await fetchJson(`http://${ip}:${port}/terminate?workflowName=3&id=${id}`); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { status: { status: string } }; + expect(result.status.status).toBe("terminated"); + }, + { timeout: 5000 } + ); + }); + + test("restart a running workflow", async ({ expect }) => { + const id = randomUUID(); + + await fetchJson(`http://${ip}:${port}/create?workflowName=3&id=${id}`); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { + status: { + __LOCAL_DEV_STEP_OUTPUTS: { output: string }[]; + }; + }; + expect(result.status.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Restart the instance + await fetchJson(`http://${ip}:${port}/restart?workflowName=3&id=${id}`); + + // After restart, wait for it to be running again + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { status: { status: string } }; + expect(result.status.status).toBe("running"); + }, + { timeout: 5000 } + ); + + // Send event to complete the restarted workflow + await fetchJson( + `http://${ip}:${port}/sendEvent?workflowName=3&id=${id}`, + { done: true }, + "POST" + ); + + await vi.waitFor( + async () => { + const result = (await fetchJson( + `http://${ip}:${port}/status?workflowName=3&id=${id}` + )) as { status: { status: string; output: string } }; + expect(result.status.status).toBe("complete"); + expect(result.status.output).toBe("i'm workflow3"); + }, + { timeout: 5000 } + ); + }); + }); +}); diff --git a/fixtures/workflow-multiple/tests/tsconfig.json b/fixtures/workflow-multiple/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/workflow-multiple/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/workflow-multiple/tsconfig.json b/fixtures/workflow-multiple/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/workflow-multiple/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/workflow-multiple/vitest.config.mts b/fixtures/workflow-multiple/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/workflow-multiple/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/workflow-multiple/wrangler.jsonc b/fixtures/workflow-multiple/wrangler.jsonc new file mode 100644 index 0000000..08061b6 --- /dev/null +++ b/fixtures/workflow-multiple/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "my-workflow-demo", + "main": "src/index.ts", + "compatibility_date": "2024-10-22", + "workflows": [ + { + "binding": "WORKFLOW", + "name": "my-workflow", + "class_name": "Demo", + }, + { + "binding": "WORKFLOW2", + "name": "my-workflow-2", + "class_name": "Demo2", + }, + { + "binding": "WORKFLOW3", + "name": "my-workflow-3", + "class_name": "Demo3", + }, + ], +} diff --git a/fixtures/workflow/package.json b/fixtures/workflow/package.json new file mode 100644 index 0000000..316ad79 --- /dev/null +++ b/fixtures/workflow/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fixture/workflow-single", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "start": "wrangler dev", + "test:ci": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "typescript": "catalog:default", + "undici": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + } +} diff --git a/fixtures/workflow/src/index.ts b/fixtures/workflow/src/index.ts new file mode 100644 index 0000000..ccc6435 --- /dev/null +++ b/fixtures/workflow/src/index.ts @@ -0,0 +1,175 @@ +import { + WorkerEntrypoint, + WorkflowBackoff, + WorkflowEntrypoint, + WorkflowEvent, + WorkflowStep, +} from "cloudflare:workers"; +import { NonRetryableError } from "cloudflare:workflows"; + +type Params = { + name: string; +}; + +export class Demo extends WorkflowEntrypoint<{}, Params> { + async run(event: WorkflowEvent, step: WorkflowStep) { + const { timestamp, payload } = event; + + const result = await step.do("First step", async function () { + return { + output: "First step result", + }; + }); + + await step.sleep("Wait", "1 second"); + + const result2 = await step.do("Second step", async function () { + return { + output: "Second step result", + }; + }); + + return payload ?? "no-payload"; + } +} + +export class Demo2 extends WorkflowEntrypoint<{}, Params> { + async run(event: WorkflowEvent, step: WorkflowStep) { + const { timestamp, payload } = event; + + const result = await step.do("First step", async function () { + return { + output: "First step result", + }; + }); + + await step.waitForEvent("event-1 provider", { + type: "event-1", + }); + + const result2 = await step.do("Second step", async function () { + return { + output: "Second step result", + }; + }); + + return payload ?? "no-payload"; + } +} + +export class Demo3 extends WorkflowEntrypoint<{}, Params> { + async run( + event: WorkflowEvent, + step: WorkflowStep + ) { + let runs = -1; + try { + await step.do( + "First (and only step) step", + { + retries: { + limit: 3, + delay: 100, + }, + }, + async function () { + runs++; + if (event.payload.doRetry) { + throw new Error(event.payload.errorMessage); + } else { + throw new NonRetryableError(event.payload.errorMessage); + } + } + ); + } catch {} + + return `The step was retried ${runs} time${runs === 1 ? "" : "s"}`; + } +} + +type Env = { + WORKFLOW: Workflow; + WORKFLOW2: Workflow; + WORKFLOW3: Workflow<{ doRetry: boolean; errorMessage: string }>; +}; +export default class extends WorkerEntrypoint { + async fetch(req: Request) { + const url = new URL(req.url); + const id = url.searchParams.get("workflowName"); + const doRetry = url.searchParams.get("doRetry"); + const errorMessage = url.searchParams.get("errorMessage"); + + if (url.pathname === "/favicon.ico") { + return new Response(null, { status: 404 }); + } + + let handle: WorkflowInstance; + if (url.pathname === "/createBatch") { + // creates two instances + const batch = await this.env.WORKFLOW.createBatch([ + { id: "batch-1", params: "1" }, + { id: "batch-2", params: "2" }, + ]); + return Response.json(batch.map((instance) => instance.id)); + } else if (url.pathname === "/create") { + if (id === null) { + handle = await this.env.WORKFLOW.create(); + } else { + handle = await this.env.WORKFLOW.create({ id }); + } + } else if (url.pathname === "/createDemo2") { + if (id === null) { + handle = await this.env.WORKFLOW2.create(); + } else { + handle = await this.env.WORKFLOW2.create({ id }); + } + } else if (url.pathname === "/createDemo3") { + if (id === null) { + handle = await this.env.WORKFLOW3.create(); + } else { + handle = await this.env.WORKFLOW3.create({ + id, + params: { + doRetry: doRetry === "false" ? false : true, + errorMessage: errorMessage ?? "", + }, + }); + } + } else if (url.pathname === "/sendEvent") { + handle = await this.env.WORKFLOW2.get(id); + + await handle.sendEvent({ + type: "event-1", + payload: await req.json(), + }); + } else if (url.pathname === "/pause") { + handle = await this.env.WORKFLOW2.get(id); + await handle.pause(); + } else if (url.pathname === "/resume") { + handle = await this.env.WORKFLOW2.get(id); + await handle.resume(); + } else if (url.pathname === "/restart") { + handle = await this.env.WORKFLOW2.get(id); + await handle.restart(); + } else if (url.pathname === "/terminate") { + handle = await this.env.WORKFLOW2.get(id); + await handle.terminate(); + } else if (url.pathname === "/get2") { + handle = await this.env.WORKFLOW2.get(id); + } else if (url.pathname === "/get3") { + handle = await this.env.WORKFLOW3.get(id); + } else if (url.pathname === "/createWithRedirect") { + console.log("create with redirect called"); + handle = await this.env.WORKFLOW.create(); + + return Response.redirect( + new URL(`/status?workflowName=${handle.id}`, url).toString(), + 302 + ); + } else if (url.pathname === "/status") { + handle = await this.env.WORKFLOW.get(id); + } + + return Response.json(await handle.status()); + } +} diff --git a/fixtures/workflow/tests/index.test.ts b/fixtures/workflow/tests/index.test.ts new file mode 100644 index 0000000..d3337fc --- /dev/null +++ b/fixtures/workflow/tests/index.test.ts @@ -0,0 +1,699 @@ +import { randomUUID } from "crypto"; +import { rm } from "fs/promises"; +import { fork } from "node:child_process"; +import { resolve } from "path"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, it, test, vi } from "vitest"; +import { + runWranglerDev, + wranglerEntryPath, +} from "../../shared/src/run-wrangler-long-lived"; + +describe("Workflows", () => { + let ip: string, + port: number, + stop: (() => Promise) | undefined, + getOutput: () => string; + + beforeAll(async () => { + // delete previous run contents because of persistence + await rm(resolve(__dirname, "..") + "/.wrangler", { + force: true, + recursive: true, + }); + ({ ip, port, stop, getOutput } = await runWranglerDev( + resolve(__dirname, ".."), + ["--port=0", "--inspector-port=0"] + )); + }); + + afterAll(async () => { + await stop?.(); + }); + + async function fetchJson(url: string, body?: unknown, method?: string) { + const response = await fetch(url, { + headers: { + "MF-Disable-Pretty-Error": "1", + }, + method: method ?? "GET", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`Couldn't parse JSON:\n\n${text}`); + } + } + + it("creates a workflow with id", async ({ expect }) => { + await expect(fetchJson(`http://${ip}:${port}/create?workflowName=test`)) + .resolves.toMatchInlineSnapshot(` + { + "__LOCAL_DEV_STEP_OUTPUTS": [ + { + "output": "First step result", + }, + ], + "output": null, + "status": "running", + } + `); + + await vi.waitFor( + async () => { + await expect(fetchJson(`http://${ip}:${port}/status?workflowName=test`)) + .resolves.toMatchInlineSnapshot(` + { + "__LOCAL_DEV_STEP_OUTPUTS": [ + { + "output": "First step result", + }, + ], + "output": null, + "status": "running", + } + `); + }, + { timeout: 5000 } + ); + }); + + it("creates a workflow without id", async ({ expect }) => { + await expect(fetchJson(`http://${ip}:${port}/create`)).resolves + .toMatchInlineSnapshot(` + { + "__LOCAL_DEV_STEP_OUTPUTS": [ + { + "output": "First step result", + }, + ], + "output": null, + "status": "running", + } + `); + }); + + it("fails getting a workflow without creating it first", async ({ + expect, + }) => { + await expect( + fetchJson(`http://${ip}:${port}/status?workflowName=anotherTest`) + ).resolves.toMatchObject({ + message: "instance.not_found", + name: "Error", + }); + }); + + test("batchCreate should create multiple instances and run them separately", async ({ + expect, + }) => { + await expect(fetchJson(`http://${ip}:${port}/createBatch`)).resolves + .toMatchInlineSnapshot(` + [ + "batch-1", + "batch-2", + ] + `); + + await Promise.all([ + vi.waitFor( + async () => { + await expect( + fetchJson(`http://${ip}:${port}/status?workflowName=batch-1`) + ).resolves.toStrictEqual({ + status: "complete", + __LOCAL_DEV_STEP_OUTPUTS: [ + { output: "First step result" }, + { output: "Second step result" }, + ], + output: "1", + }); + }, + { timeout: 5000 } + ), + vi.waitFor( + async () => { + await expect( + fetchJson(`http://${ip}:${port}/status?workflowName=batch-2`) + ).resolves.toStrictEqual({ + status: "complete", + __LOCAL_DEV_STEP_OUTPUTS: [ + { output: "First step result" }, + { output: "Second step result" }, + ], + output: "2", + }); + }, + { timeout: 5000 } + ), + ]); + }); + + describe("retrying a step", () => { + test("should retry a step if a generic Error (with a generic error message) is thrown", async ({ + expect, + }) => { + const name = randomUUID(); + await fetchJson( + `http://${ip}:${port}/createDemo3?workflowName=${name}&doRetry=true&errorMessage=generic_error_message` + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get3?workflowName=${name}` + ); + + expect(result["output"]).toEqual("The step was retried 3 times"); + }, + { timeout: 1500 } + ); + }); + + test("should retry a step if a generic Error (with an empty error message) is thrown", async ({ + expect, + }) => { + const name = randomUUID(); + await fetchJson( + `http://${ip}:${port}/createDemo3?workflowName=${name}&doRetry=true&errorMessage=` + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get3?workflowName=${name}` + ); + + expect(result["output"]).toEqual("The step was retried 3 times"); + }, + { timeout: 1500 } + ); + }); + + test("should not retry a step if a NonRetryableError (with a generic error message) is thrown", async ({ + expect, + }) => { + const name = randomUUID(); + await fetchJson( + `http://${ip}:${port}/createDemo3?workflowName=${name}&doRetry=false&errorMessage=generic_error_message` + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get3?workflowName=${name}` + ); + + expect(result["output"]).toEqual("The step was retried 0 times"); + }, + { timeout: 1500 } + ); + }); + + test("should not retry a step if a NonRetryableError (with an empty error message) is thrown", async ({ + expect, + }) => { + const name = randomUUID(); + await fetchJson( + `http://${ip}:${port}/createDemo3?workflowName=${name}&doRetry=false&errorMessage=` + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get3?workflowName=${name}` + ); + + expect(result["output"]).toEqual("The step was retried 0 times"); + }, + { timeout: 1500 } + ); + }); + }); + + test("waitForEvent should work", async ({ expect }) => { + await fetchJson(`http://${ip}:${port}/createDemo2?workflowName=something`); + + await fetchJson( + `http://${ip}:${port}/sendEvent?workflowName=something`, + { event: true }, + "POST" + ); + + await vi.waitFor( + async () => { + await expect( + fetchJson(`http://${ip}:${port}/get2?workflowName=something`) + ).resolves.toMatchInlineSnapshot(` + { + "__LOCAL_DEV_STEP_OUTPUTS": [ + { + "output": "First step result", + }, + { + "event": true, + }, + { + "output": "Second step result", + }, + ], + "output": {}, + "status": "complete", + } + `); + }, + { timeout: 5000 } + ); + }); + + describe("instance lifecycle methods", () => { + test("pause and resume a workflow", async ({ expect }) => { + const name = randomUUID(); + + await fetchJson(`http://${ip}:${port}/createDemo2?workflowName=${name}`); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 1500 } + ); + + // Pause the instance + await fetchJson(`http://${ip}:${port}/pause?workflowName=${name}`); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.status).toBe("paused"); + }, + { timeout: 1500 } + ); + + // Resume the instance + await fetchJson(`http://${ip}:${port}/resume?workflowName=${name}`); + + await fetchJson( + `http://${ip}:${port}/sendEvent?workflowName=${name}`, + { event: true }, + "POST" + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.status).toBe("complete"); + }, + { timeout: 5000 } + ); + }); + + test("terminate a running workflow", async ({ expect }) => { + const name = randomUUID(); + + await fetchJson(`http://${ip}:${port}/createDemo2?workflowName=${name}`); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 1500 } + ); + + // Terminate the instance + await fetchJson(`http://${ip}:${port}/terminate?workflowName=${name}`); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.status).toBe("terminated"); + }, + { timeout: 1500 } + ); + }); + + test("restart a running workflow", async ({ expect }) => { + const name = randomUUID(); + + await fetchJson(`http://${ip}:${port}/createDemo2?workflowName=${name}`); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Restart the instance + await fetchJson(`http://${ip}:${port}/restart?workflowName=${name}`); + + // After restart, wait for it to be running again + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.status).toBe("running"); + }, + { timeout: 1500 } + ); + + // Send event to complete the restarted workflow + await fetchJson( + `http://${ip}:${port}/sendEvent?workflowName=${name}`, + { event: true }, + "POST" + ); + + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=${name}` + ); + expect(result.status).toBe("complete"); + }, + { timeout: 5000 } + ); + }); + }); + + it("should create an instance after immediate redirect", async ({ + expect, + }) => { + await expect(fetchJson(`http://${ip}:${port}/createWithRedirect`)).resolves + .toMatchInlineSnapshot(` + { + "__LOCAL_DEV_STEP_OUTPUTS": [ + { + "output": "First step result", + }, + ], + "output": null, + "status": "running", + } + `); + }); + + // ========================================================================= + // Local CLI commands (wrangler workflows ... --local --port=) + // ========================================================================= + + /** + * Run a short-lived wrangler CLI command and return its combined output. + * Merges stdout + stderr into a single `output` string (wrangler routes + * log messages across both streams depending on the logger method). + */ + function runWranglerCommand( + args: string[] + ): Promise<{ output: string; exitCode: number | null }> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + + const child = fork(wranglerEntryPath, args, { + stdio: ["ignore", "pipe", "pipe", "ipc"], + cwd: __dirname, + }); + + child.stdout?.on("data", (chunk) => chunks.push(chunk)); + child.stderr?.on("data", (chunk) => chunks.push(chunk)); + + child.on("exit", (exitCode) => { + resolve({ + output: Buffer.concat(chunks).toString(), + exitCode, + }); + }); + }); + } + + describe("local CLI commands", () => { + it("workflows list --local", async ({ expect }) => { + const result = await runWranglerCommand([ + "workflows", + "list", + "--local", + `--port=${port}`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain( + "Showing 3 workflows from local dev session:" + ); + expect(result.output).toContain("my-workflow"); + expect(result.output).toContain("my-workflow2"); + expect(result.output).toContain("my-workflow3"); + expect(result.output).toContain("my-workflow-demo"); + expect(result.output).toContain("Demo"); + expect(result.output).toContain("Demo2"); + expect(result.output).toContain("Demo3"); + }); + + it("workflows describe --local", async ({ expect }) => { + const result = await runWranglerCommand([ + "workflows", + "describe", + "my-workflow", + "--local", + `--port=${port}`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("my-workflow"); + expect(result.output).toContain("my-workflow-demo"); + expect(result.output).toContain("Demo"); + expect(result.output).toContain("Instance Status Counts:"); + }); + + it("workflows trigger --local and instances list --local", async ({ + expect, + }) => { + const result = await runWranglerCommand([ + "workflows", + "trigger", + "my-workflow", + "--local", + `--port=${port}`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("has been triggered successfully"); + + // Wait for the instance to register + await vi.waitFor( + async () => { + const listResult = await runWranglerCommand([ + "workflows", + "instances", + "list", + "my-workflow", + "--local", + `--port=${port}`, + ]); + + expect(listResult.exitCode).toBe(0); + expect(listResult.output).toContain("from page 1:"); + }, + { timeout: 5000 } + ); + }); + + it("workflows instances describe --local", async ({ expect }) => { + // Trigger an instance first + const triggerResult = await runWranglerCommand([ + "workflows", + "trigger", + "my-workflow", + "--local", + `--port=${port}`, + ]); + expect(triggerResult.exitCode).toBe(0); + + // Describe latest + await vi.waitFor( + async () => { + const result = await runWranglerCommand([ + "workflows", + "instances", + "describe", + "my-workflow", + "latest", + "--local", + `--port=${port}`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Describing latest instance:"); + expect(result.output).toContain("my-workflow"); + expect(result.output).toContain("Steps:"); + }, + { timeout: 5000 } + ); + }); + + it("workflows instances pause and resume --local", async ({ expect }) => { + // Create a Demo2 instance (has waitForEvent so it stays running) + await fetchJson( + `http://${ip}:${port}/createDemo2?workflowName=pause-resume-test` + ); + + // Wait for it to be running + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=pause-resume-test` + ); + expect(result.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Pause via CLI + const pauseResult = await runWranglerCommand([ + "workflows", + "instances", + "pause", + "my-workflow2", + "pause-resume-test", + "--local", + `--port=${port}`, + ]); + expect(pauseResult.exitCode).toBe(0); + expect(pauseResult.output).toContain( + 'The instance "pause-resume-test" from my-workflow2 was paused successfully' + ); + + // Verify paused via binding + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=pause-resume-test` + ); + expect(result.status).toBe("paused"); + }, + { timeout: 5000 } + ); + + // Resume via CLI + const resumeResult = await runWranglerCommand([ + "workflows", + "instances", + "resume", + "my-workflow2", + "pause-resume-test", + "--local", + `--port=${port}`, + ]); + expect(resumeResult.exitCode).toBe(0); + expect(resumeResult.output).toContain( + 'The instance "pause-resume-test" from my-workflow2 was resumed successfully' + ); + }); + + it("workflows instances terminate --local", async ({ expect }) => { + // Create a Demo2 instance (has waitForEvent so it stays running) + await fetchJson( + `http://${ip}:${port}/createDemo2?workflowName=terminate-test` + ); + + // Wait for it to be running + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=terminate-test` + ); + expect(result.__LOCAL_DEV_STEP_OUTPUTS).toContainEqual({ + output: "First step result", + }); + }, + { timeout: 5000 } + ); + + // Terminate via CLI + const terminateResult = await runWranglerCommand([ + "workflows", + "instances", + "terminate", + "my-workflow2", + "terminate-test", + "--local", + `--port=${port}`, + ]); + expect(terminateResult.exitCode).toBe(0); + expect(terminateResult.output).toContain( + 'The instance "terminate-test" from my-workflow2 was terminated successfully' + ); + + // Verify terminated via binding + await vi.waitFor( + async () => { + const result = await fetchJson( + `http://${ip}:${port}/get2?workflowName=terminate-test` + ); + expect(result.status).toBe("terminated"); + }, + { timeout: 5000 } + ); + }); + + it("workflows delete --local", async ({ expect }) => { + const result = await runWranglerCommand([ + "workflows", + "delete", + "my-workflow3", + "--local", + `--port=${port}`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain( + 'Workflow "my-workflow3" instances removed successfully from local dev session.' + ); + }); + }); + + it("should persist instances across lifetimes", async ({ expect }) => { + await fetchJson(`http://${ip}:${port}/create?workflowName=something`); + + await stop?.(); + + const { port: portChild2, stop: stopChild2Process } = await runWranglerDev( + resolve(__dirname, ".."), + [`--port=0`, `--inspector-port=0`] + ); + + try { + const result = await fetchJson( + `http://${ip}:${portChild2}/status?workflowName=something` + ); + expect(result).not.toBeUndefined(); + } finally { + await stopChild2Process?.(); + } + }); +}); diff --git a/fixtures/workflow/tests/tsconfig.json b/fixtures/workflow/tests/tsconfig.json new file mode 100644 index 0000000..be0dabd --- /dev/null +++ b/fixtures/workflow/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/fixtures/workflow/tsconfig.json b/fixtures/workflow/tsconfig.json new file mode 100644 index 0000000..6112e71 --- /dev/null +++ b/fixtures/workflow/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "preserve", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/workflow/vitest.config.mts b/fixtures/workflow/vitest.config.mts new file mode 100644 index 0000000..846cddc --- /dev/null +++ b/fixtures/workflow/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/workflow/wrangler.jsonc b/fixtures/workflow/wrangler.jsonc new file mode 100644 index 0000000..bee45e9 --- /dev/null +++ b/fixtures/workflow/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "name": "my-workflow-demo", + "main": "src/index.ts", + "compatibility_date": "2024-10-22", + "workflows": [ + { + "binding": "WORKFLOW", + "name": "my-workflow", + "class_name": "Demo", + }, + { + "binding": "WORKFLOW2", + "name": "my-workflow2", + "class_name": "Demo2", + }, + { + "binding": "WORKFLOW3", + "name": "my-workflow3", + "class_name": "Demo3", + }, + ], +} diff --git a/lint-turbo.mjs b/lint-turbo.mjs new file mode 100644 index 0000000..c53f6df --- /dev/null +++ b/lint-turbo.mjs @@ -0,0 +1,63 @@ +import assert from "assert"; +import { execSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import { parse } from "jsonc-parser"; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function readJsonc(filePath) { + return parse(readFileSync(filePath, "utf8")); +} + +const listResult = execSync( + "pnpm --filter=!@cloudflare/workers-sdk list --recursive --depth -1 --parseable" +); +const paths = listResult.toString().trim().split("\n"); +const vitePluginPlaygroundDir = path.join( + "packages", + "vite-plugin-cloudflare", + "playground" +); + +for (const p of paths) { + if (!path.isAbsolute(p)) continue; + + const pkg = readJson(path.join(p, "package.json")); + const relativePath = path.relative(process.cwd(), p); + + // Ensure playground packages don't have a "build" script (use "build:default" instead) + if (relativePath.startsWith(`${vitePluginPlaygroundDir}${path.sep}`)) { + assert( + !pkg.scripts?.build, + `Vite plugin playground package "${pkg.name}" should not have a "build" script. Use "build:default" instead.` + ); + } + + // Ensure all packages with a build script have a turbo build output configured + if (pkg.scripts?.build) { + console.log(pkg.name, "has build script. Checking turbo.json"); + let turboConfig; + try { + turboConfig = readJsonc(path.join(p, "turbo.json")); + } catch { + console.log("Failed to read turbo.json for", pkg.name); + process.exit(1); + } + const buildOutputs = turboConfig.tasks.build.outputs; + assert(buildOutputs.length > 0); + } + + // Ensure all packages with a vitest config file have a "test:ci" script + if ( + existsSync(path.join(p, "vitest.config.ts")) || + existsSync(path.join(p, "vitest.config.mts")) + ) { + assert( + pkg.scripts["test:ci"], + `Package "${p}" is missing a "test:ci" script in package.json` + ); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..26bc18e --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cloudflare/workers-sdk", + "version": "0.0.0", + "private": true, + "description": "Monorepo for wrangler and associated packages", + "homepage": "https://github.com/cloudflare/workers-sdk#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT OR Apache-2.0", + "author": "wrangler@cloudflare.com", + "scripts": { + "build": "dotenv -- turbo build", + "check": "pnpm check:fixtures && pnpm check:private-packages && pnpm check:package-deps && pnpm check:catalog && pnpm check:pinned-deps && pnpm check:deployments && pnpm check:workflows && node lint-turbo.mjs && dotenv -- turbo check:lint check:type check:format type:tests", + "check:catalog": "node -r esbuild-register tools/deployments/validate-catalog-usage.ts", + "check:deployments": "node -r esbuild-register tools/deployments/deploy-non-npm-packages.ts check", + "check:fixtures": "node -r esbuild-register tools/deployments/validate-fixtures.ts", + "check:format": "oxfmt --check", + "check:lint": "oxlint --deny-warnings --type-aware", + "check:package-deps": "pnpm build && node -r esbuild-register tools/deployments/validate-package-dependencies.ts", + "check:pinned-deps": "node -r esbuild-register tools/deployments/validate-pinned-dependencies.ts", + "check:private-packages": "node -r esbuild-register tools/deployments/validate-private-packages.ts", + "check:type": "dotenv -- turbo check:type type:tests", + "check:workflows": "node -r esbuild-register tools/github-workflow-helpers/validate-action-pinning.ts", + "dev": "dotenv -- turbo dev", + "fix": "oxlint --fix --type-aware && pnpm run prettify", + "prettify": "oxfmt", + "test": "dotenv -- turbo test", + "test:ci": "dotenv -- turbo test:ci", + "test:e2e": "dotenv -- turbo test:e2e", + "test:e2e:c3": "dotenv -- node -r esbuild-register packages/create-cloudflare/scripts/e2e/run-tests.ts", + "test:e2e:wrangler": "dotenv -- node -r esbuild-register tools/e2e/runIndividualE2EFiles.ts", + "test:watch": "turbo test:watch", + "type:tests": "dotenv -- turbo type:tests" + }, + "dependencies": { + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.29.7", + "@changesets/parse": "^0.4.1", + "@manypkg/cli": "^0.23.0", + "@types/node": "catalog:default", + "@vue/compiler-sfc": "^3.3.4", + "cross-env": "^7.0.3", + "dotenv-cli": "^7.3.0", + "esbuild": "catalog:default", + "esbuild-register": "^3.5.0", + "eslint-plugin-turbo": "^2.8.17", + "jsonc-parser": "catalog:default", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", + "oxlint-tsgolint": "^0.17.0", + "tree-kill": "catalog:default", + "turbo": "^2.7.2", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": "^10.33.0" + }, + "volta": { + "node": "22.22.1", + "pnpm": "10.33.0" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/packages/autoconfig/CHANGELOG.md b/packages/autoconfig/CHANGELOG.md new file mode 100644 index 0000000..405811f --- /dev/null +++ b/packages/autoconfig/CHANGELOG.md @@ -0,0 +1,51 @@ +# @cloudflare/autoconfig + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [[`0283a1f`](https://github.com/cloudflare/workers-sdk/commit/0283a1fcdc635244f731010422e513e8b4ab0be3)]: + - @cloudflare/workers-utils@0.26.0 + - @cloudflare/cli-shared-helpers@0.1.13 + +## 0.1.3 + +### Patch Changes + +- [#14548](https://github.com/cloudflare/workers-sdk/pull/14548) [`383e679`](https://github.com/cloudflare/workers-sdk/commit/383e679b52e39dcb71cbbb66909218c008d9aac4) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix Vite version detection for vite+ projects during autoconfiguration + + vite+ installs `@voidzero-dev/vite-plus-core` under the `vite` npm alias, so the resolved `node_modules/vite/package.json` reports the wrapper's own version (e.g. `0.2.2`) rather than the underlying Vite version it bundles. This caused autoconfiguration to fail with an error claiming the Vite version was too old to be configured automatically. Autoconfiguration now detects the correct underlying Vite version for these projects. + +- Updated dependencies [[`aad35b7`](https://github.com/cloudflare/workers-sdk/commit/aad35b79d07df1bb764a4a5912d6b4328a34474b), [`1ac96a1`](https://github.com/cloudflare/workers-sdk/commit/1ac96a14b7fb022acada114ab8793fe8a4ba79a5), [`1ca8d8f`](https://github.com/cloudflare/workers-sdk/commit/1ca8d8f0bbd012a1d65cabadf7b6987b252775e9)]: + - @cloudflare/workers-utils@0.25.1 + - @cloudflare/cli-shared-helpers@0.1.12 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`aa5d580`](https://github.com/cloudflare/workers-sdk/commit/aa5d5801450b7e4417bfdbd477f86de3a4bc6933)]: + - @cloudflare/workers-utils@0.25.0 + - @cloudflare/cli-shared-helpers@0.1.11 + +## 0.1.1 + +### Patch Changes + +- [#14368](https://github.com/cloudflare/workers-sdk/pull/14368) [`a55b29a`](https://github.com/cloudflare/workers-sdk/commit/a55b29a4ba8a24f4d539538e2bf38e6a7e5b8e52) Thanks [@penalosa](https://github.com/penalosa)! - Add repository URL to `@cloudflare/autoconfig` + +## 0.1.0 + +### Minor Changes + +- [#14365](https://github.com/cloudflare/workers-sdk/pull/14365) [`f224ce2`](https://github.com/cloudflare/workers-sdk/commit/f224ce2009b6844a606eb53a71fb114434e8a7a0) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add experimental package for framework autoconfig detection and configuration + +- [#14365](https://github.com/cloudflare/workers-sdk/pull/14365) [`f224ce2`](https://github.com/cloudflare/workers-sdk/commit/f224ce2009b6844a606eb53a71fb114434e8a7a0) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add support for React Router >= 8.0.0 + + React Router v8 enables `viteEnvironmentApi` and `middleware` by default, so autoconfig no longer adds `future` flags to `react-router.config.ts` for v8+ projects and uses the middleware code pattern unconditionally. + +### Patch Changes + +- Updated dependencies [[`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359), [`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359)]: + - @cloudflare/workers-utils@0.24.0 + - @cloudflare/cli-shared-helpers@0.1.10 diff --git a/packages/autoconfig/package.json b/packages/autoconfig/package.json new file mode 100644 index 0000000..276b31e --- /dev/null +++ b/packages/autoconfig/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cloudflare/autoconfig", + "version": "0.1.4", + "description": "Framework autoconfig detection and configuration for Cloudflare Workers", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/autoconfig" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsup", + "check:type": "tsc -p ./tsconfig.json", + "dev": "tsup --watch", + "test": "vitest", + "test:ci": "vitest run" + }, + "dependencies": { + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/workers-utils": "workspace:*" + }, + "devDependencies": { + "@cloudflare/codemod": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@netlify/build-info": "^10.5.1", + "@types/esprima": "^4.0.3", + "@types/node": "catalog:default", + "chalk": "catalog:default", + "empathic": "^2.0.0", + "esprima": "4.0.1", + "recast": "0.23.11", + "semiver": "^1.1.0", + "ts-dedent": "^2.2.0", + "tsup": "8.3.0", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "prerelease": true + } +} diff --git a/packages/autoconfig/scripts/deps.ts b/packages/autoconfig/scripts/deps.ts new file mode 100644 index 0000000..6d7361e --- /dev/null +++ b/packages/autoconfig/scripts/deps.ts @@ -0,0 +1,12 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/autoconfig. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Published workspace packages that consumers must install alongside autoconfig. + // They are kept external to share a single copy with wrangler and other SDK tools. + "@cloudflare/cli-shared-helpers", + "@cloudflare/workers-utils", +]; diff --git a/packages/autoconfig/src/context.ts b/packages/autoconfig/src/context.ts new file mode 100644 index 0000000..8c70cd9 --- /dev/null +++ b/packages/autoconfig/src/context.ts @@ -0,0 +1,107 @@ +/** + * Logger interface for autoconfig output. + * Callers provide their own implementation (e.g., wrapping `console` or a custom logger). + */ +export interface AutoConfigLogger { + /** Logs informational output. */ + log(...args: unknown[]): void; + /** Logs an informational message. */ + info(...args: unknown[]): void; + /** Logs a warning message. */ + warn(...args: unknown[]): void; + /** Logs a debug-level message (may be suppressed in production). */ + debug(...args: unknown[]): void; + /** Logs an error message. */ + error(...args: unknown[]): void; +} + +/** + * Dialog interface for interactive prompts. + * Callers provide their own implementation (e.g., using `prompts`, `inquirer`, or a custom UI). + */ +export interface AutoConfigDialogs { + /** + * Asks a yes/no confirmation question. + * + * @param text - The question to display + * @param options - Optional defaults and fallback behavior + * @returns `true` if confirmed, `false` otherwise + */ + confirm( + text: string, + options?: { defaultValue?: boolean; fallbackValue?: boolean } + ): Promise; + + /** + * Prompts the user for a text input. + * + * @param text - The prompt message + * @param options - Optional default value and validation function + * @returns The user-provided string + */ + prompt( + text: string, + options?: { + defaultValue?: string; + validate?: ( + value: string + ) => boolean | string | Promise; + } + ): Promise; + + /** + * Presents a selection list to the user. + * + * @param text - The prompt message + * @param options - Available choices and optional default selection + * @returns The selected value + */ + select( + text: string, + options: { + choices: Array<{ + title: string; + value: string; + description?: string; + }>; + defaultOption?: number; + } + ): Promise; +} + +/** + * Context object that provides external dependencies to the autoconfig system. + * + * Callers must provide implementations for `logger` and `dialogs`. + * All other fields are optional and allow callers to customize behavior + * (e.g., error reporting, command execution, CI detection). + */ +export interface AutoConfigContext { + /** Logger used for all autoconfig output. */ + logger: AutoConfigLogger; + /** Dialogs used for interactive prompts. */ + dialogs: AutoConfigDialogs; + /** + * Runs a shell command in the given directory. + * + * @param command - The shell command string to execute + * @param cwd - The working directory for the command + * @param label - A short label for logging (e.g., "[build]") + * @returns A promise that resolves when the command completes + */ + runCommand: (command: string, cwd: string, label: string) => Promise; + /** + * Returns `true` if running in a non-interactive or CI environment. + * Defaults to `() => false` if not provided. + * + * @returns Whether the current environment is non-interactive + */ + isNonInteractiveOrCI?: () => boolean; + /** + * Returns a cache folder path used for detecting cached project state, + * or `undefined` if not available. + * + * @returns The cache folder path, or `undefined` + */ + getCacheFolder?: () => string | undefined; +} diff --git a/packages/autoconfig/src/details/framework-detection.ts b/packages/autoconfig/src/details/framework-detection.ts new file mode 100644 index 0000000..3a64c2f --- /dev/null +++ b/packages/autoconfig/src/details/framework-detection.ts @@ -0,0 +1,338 @@ +import { existsSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + BunPackageManager, + FatalError, + NpmPackageManager, + PnpmPackageManager, + UserError, + YarnPackageManager, +} from "@cloudflare/workers-utils"; +import { Project } from "@netlify/build-info"; +import { NodeFS } from "@netlify/build-info/node"; +import chalk from "chalk"; +import dedent from "ts-dedent"; +import { isKnownFramework } from "../frameworks"; +import { staticFramework } from "../frameworks/all-frameworks"; +import type { AutoConfigContext } from "../context"; +import type { PackageManager } from "@cloudflare/workers-utils"; +import type { Config, TelemetryMessage } from "@cloudflare/workers-utils"; +import type { Settings } from "@netlify/build-info"; + +/** + * Detects the framework used by the project at the given path. + * + * Uses `@netlify/build-info` to analyze build settings and identify the + * framework, then maps the detected package manager to wrangler's own type. + * + * If the project is identified as a Cloudflare Pages project the function + * returns early with a synthetic "Cloudflare Pages" framework entry. + * + * @param projectPath Path to the project root + * @param wranglerConfig Optional parsed wrangler config for the project + * @returns An object containing: + * - `detectedFramework`: The matched framework together with its build + * command and output directory. + * - `packageManager`: The package manager detected in the project. + * - `isWorkspaceRoot`: `true` when the project path is the root of a + * monorepo workspace (only present when relevant). + * @throws {UserError} When called from a workspace root that does not itself + * contain the targeted project path. + * @throws {MultipleFrameworksCIError} (via `findDetectedFramework`) in CI / + * non-interactive environments when multiple known frameworks are detected + * and no clear winner can be determined. + */ +export async function detectFramework( + projectPath: string, + context: AutoConfigContext, + wranglerConfig?: Config +): Promise<{ + detectedFramework: DetectedFramework; + packageManager: PackageManager; + isWorkspaceRoot?: boolean; +}> { + const fs = new NodeFS(); + + fs.logger = context.logger; + + const project = new Project(fs, projectPath, projectPath) + .setEnvironment(process.env) + .setNodeVersion(process.version); + + const buildSettings = await project.getBuildSettings(); + + const isWorkspaceRoot = !!project.workspace?.isRoot; + + if (isWorkspaceRoot) { + const resolvedProjectPath = resolve(projectPath); + + const workspaceRootIncludesProject = project.workspace?.packages.some( + (pkg) => resolve(pkg.path) === resolvedProjectPath + ); + + if (!workspaceRootIncludesProject) { + throw new UserError( + "The Cloudflare application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again.", + { + telemetryMessage: "autoconfig detection workspace root unsupported", + } + ); + } + } + + // Convert the package manager detected by @netlify/build-info to our PackageManager type. + // This is populated after getBuildSettings() runs, which triggers the full detection chain. + const packageManager = convertDetectedPackageManager(project.packageManager); + + const lockFileExists = packageManager.lockFiles.some((lockFile) => + existsSync(join(projectPath, lockFile)) + ); + + const maybeDetectedFramework = maybeFindDetectedFramework( + buildSettings, + context + ); + + if ( + await isPagesProject( + projectPath, + context, + wranglerConfig, + maybeDetectedFramework + ) + ) { + return { + detectedFramework: { + framework: { + name: "Cloudflare Pages", + id: "cloudflare-pages", + }, + dist: wranglerConfig?.pages_build_output_dir, + }, + packageManager, + }; + } + + const detectedFramework = maybeDetectedFramework ?? { + framework: { + id: staticFramework.id, + name: staticFramework.name, + }, + }; + + if ( + !lockFileExists && + detectedFramework.framework.id !== staticFramework.id + ) { + context.logger.warn( + "No lock file has been detected in the current working directory." + + " This might indicate that the project is part of a workspace. Auto-configuration of " + + `projects inside workspaces is limited. See ${chalk.hex("#3B818D")( + "https://developers.cloudflare.com/workers/framework-guides/automatic-configuration/#workspaces" + )}` + ); + } + + return { + detectedFramework, + packageManager, + isWorkspaceRoot, + }; +} + +/** + * Converts the package manager detected by @netlify/build-info to our PackageManager type. + * Falls back to npm if no package manager was detected. + * + * @param pkgManager The package manager detected by @netlify/build-info (from project.packageManager) + * @returns A PackageManager object compatible with wrangler's package manager utilities + */ +function convertDetectedPackageManager( + pkgManager: { name: string } | null +): PackageManager { + if (!pkgManager) { + return NpmPackageManager; + } + + switch (pkgManager?.name) { + case "pnpm": + return PnpmPackageManager; + case "yarn": + return YarnPackageManager; + case "bun": + return BunPackageManager; + case "npm": + default: + return NpmPackageManager; + } +} + +class MultipleFrameworksCIError extends FatalError { + constructor( + frameworks: string[], + telemetryOptions: TelemetryMessage = { + telemetryMessage: "autoconfig detection multiple frameworks", + } + ) { + super( + dedent`Cloudflare's tooling was unable to automatically configure your project, since multiple frameworks were found: ${frameworks.join( + ", " + )}. + + To fix this issue either: + - check your project's configuration to make sure that the target framework + is the only configured one and try again + - run \`wrangler setup\` locally to get an interactive user experience where + you can specify what framework you want to target + + `, + { ...telemetryOptions, code: 1 } + ); + } +} + +function throwMultipleFrameworksNonInteractiveError( + settings: Settings[] +): never { + throw new MultipleFrameworksCIError( + settings.map((b) => b.name), + { + telemetryMessage: "autoconfig detection multiple frameworks", + } + ); +} + +type DetectedFramework = { + framework: { + name: string; + id: string; + }; + buildCommand?: string | undefined; + dist?: string; +}; + +async function isPagesProject( + projectPath: string, + context: AutoConfigContext, + wranglerConfig: Config | undefined, + detectedFramework?: DetectedFramework | undefined +): Promise { + if (wranglerConfig?.pages_build_output_dir) { + // The `pages_build_output_dir` is set only for Pages projects + return true; + } + + const cacheFolder = context.getCacheFolder?.(); + if (cacheFolder) { + const pagesConfigCache = join(cacheFolder, "pages.json"); + if (existsSync(pagesConfigCache)) { + // If there is a cached pages.json we can safely assume that the project + // is a Pages one + return true; + } + } + + if (detectedFramework === undefined) { + const functionsPath = join(projectPath, "functions"); + if (existsSync(functionsPath)) { + const functionsStat = statSync(functionsPath); + if (functionsStat.isDirectory()) { + const pagesConfirmed = await context.dialogs.confirm( + "We have identified a `functions` directory in this project, which might indicate you have an active Cloudflare Pages deployment. Is this correct?", + { + defaultValue: true, + // In CI we do want to fallback to `false` so that we can proceed with the autoconfig flow + fallbackValue: false, + } + ); + return pagesConfirmed; + } + } + } + + return false; +} + +/** + * Selects the most appropriate framework from a list of detected framework settings. + * + * When there is a good level of confidence that the selected framework is correct or + * the process is running locally (where the user can choose a different framework or + * abort the process) the framework is returned. If there is no such confidence and the + * process is running in non interactive mode (where the user doesn't have the option to + * change the detected framework) an error is instead thrown. + * + * @param settings The array of framework settings + * @returns The selected framework settings, or `undefined` if none provided + * @throws {MultipleFrameworksCIError} In CI environments when multiple known frameworks + * are detected and no clear winner can be determined + */ +function maybeFindDetectedFramework( + settings: Settings[], + context: AutoConfigContext +): DetectedFramework | undefined { + if (settings.length === 0) { + return undefined; + } + + if (settings.length === 1) { + return settings[0]; + } + + const settingsForOnlyKnownFrameworks = settings.filter(({ framework }) => + isKnownFramework(framework.id) + ); + + if (settingsForOnlyKnownFrameworks.length === 0) { + if (context.isNonInteractiveOrCI?.() ?? false) { + // If we're in a non interactive session (e.g. CI) let's throw to be on the safe side + throwMultipleFrameworksNonInteractiveError(settings); + } + // Locally we can just return the first one since the user can anyways choose a different + // framework or abort the process anyways + return settings[0]; + } + + if (settingsForOnlyKnownFrameworks.length === 1) { + // If there is a single known framework it's quite safe to assume that that's the + // one we care about + return settingsForOnlyKnownFrameworks[0]; + } + + if (settingsForOnlyKnownFrameworks.length === 2) { + const settingsForOnlyKnownFrameworksIds = new Set( + settingsForOnlyKnownFrameworks.map(({ framework }) => framework.id) + ); + + // Some frameworks (e.g. Vite, Hono) can serve as auxiliary tooling for a primary + // framework (e.g. Vite with React, Hono with Waku). When exactly two frameworks + // are detected and one is auxiliary, we discard it and return the primary one. + const idsOfAuxiliaryFrameworks = ["vite", "hono"]; + + for (const auxiliaryFrameworkId of idsOfAuxiliaryFrameworks) { + if (settingsForOnlyKnownFrameworksIds.has(auxiliaryFrameworkId)) { + const nonAuxiliaryFrameworkSettings = + settingsForOnlyKnownFrameworks.find( + ({ framework }) => framework.id !== auxiliaryFrameworkId + ); + + // Note: here nonAuxiliaryFrameworkSettings should always be defined, it could be undefined only if the + // same framework is actually detected twice (which shouldn't be possible). + if (nonAuxiliaryFrameworkSettings) { + return nonAuxiliaryFrameworkSettings; + } + } + } + } + + // If we've detected multiple frameworks, and we're in a non interactive session (e.g. CI) let's stay on the safe side and error + // (otherwise we just pick the first one as the user is always able to choose a different framework or terminate the process anyways) + if (context.isNonInteractiveOrCI?.() ?? false) { + throw new MultipleFrameworksCIError( + settingsForOnlyKnownFrameworks.map((b) => b.name), + { telemetryMessage: "autoconfig detection multiple frameworks" } + ); + } + + return settingsForOnlyKnownFrameworks[0]; +} diff --git a/packages/autoconfig/src/details/index.ts b/packages/autoconfig/src/details/index.ts new file mode 100644 index 0000000..3ba589d --- /dev/null +++ b/packages/autoconfig/src/details/index.ts @@ -0,0 +1,349 @@ +import assert from "node:assert"; +import { statSync } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { brandColor } from "@cloudflare/cli-shared-helpers/colors"; +import { + checkWorkerNameValidity, + getWorkerName, + NpmPackageManager, + parsePackageJSON, + readFileSync, +} from "@cloudflare/workers-utils"; +import { AutoConfigDetectionError } from "../errors"; +import { getFrameworkClassInstance } from "../frameworks"; +import { + allFrameworksInfos, + staticFramework, +} from "../frameworks/all-frameworks"; +import { detectFramework } from "./framework-detection"; +import type { AutoConfigContext } from "../context"; +import type { + AutoConfigDetails, + AutoConfigDetailsForNonConfiguredProject, +} from "../types"; +import type { PackageManager } from "@cloudflare/workers-utils"; +import type { Config, PackageJSON } from "@cloudflare/workers-utils"; + +/** + * Asserts that the current project being targeted for autoconfig is not already configured. + * + * @param details The details detected for the project. + */ +export function assertNonConfigured( + details: AutoConfigDetails +): asserts details is AutoConfigDetailsForNonConfiguredProject { + assert( + details.configured === false, + "Error: expected the current project not to be already configured" + ); +} + +async function hasIndexHtml(dir: string): Promise { + const children = await readdir(dir); + for (const child of children) { + const stats = await stat(join(dir, child)); + if (stats.isFile() && child === "index.html") { + return true; + } + } + return false; +} + +/** + * If we haven't detected a framework being used, or the project is a Pages one, we need to "guess" what output dir the + * user is intending to use. This is best-effort, and so will not be accurate all the time. The heuristic we use is the + * first child directory with an `index.html` file present. + */ +async function findAssetsDir(from: string): Promise { + if (await hasIndexHtml(from)) { + return "."; + } + const children = await readdir(from); + for (const child of children) { + const path = join(from, child); + const stats = await stat(path); + if (stats.isDirectory() && (await hasIndexHtml(path))) { + return relative(from, path); + } + } + return undefined; +} + +type DetectedFramework = { + framework: { + name: string; + id: string; + }; + buildCommand?: string | undefined; + dist?: string; +}; + +/** + * Detects project details needed for autoconfig: framework, package manager, + * output directory, worker name, and whether the project is already configured. + * + * @param options - Detection options including project path, wrangler config, and context. + * @returns The detected project details. + */ +export async function getDetailsForAutoConfig({ + projectPath = process.cwd(), + wranglerConfig, + context, +}: { + /** The path to the project, defaults to cwd. */ + projectPath?: string; + /** The parsed wrangler configuration for the project (if any). */ + wranglerConfig?: Config; + /** The autoconfig context providing logger, dialogs, and other dependencies. */ + context: AutoConfigContext; +}): Promise { + const { logger } = context; + + logger.debug(`Running autoconfig detection in ${projectPath}...`); + + if ( + // If a real Wrangler config has been found the project is already configured for Workers + wranglerConfig?.configPath && + // Unless `pages_build_output_dir` is set, since that indicates that the project is a Pages one instead + !wranglerConfig.pages_build_output_dir + ) { + return { + configured: true, + projectPath, + workerName: getWorkerName(wranglerConfig.name, projectPath), + // Fall back to npm when already configured since we don't need to run package manager commands + packageManager: NpmPackageManager, + }; + } + + const { detectedFramework, packageManager, isWorkspaceRoot } = + await detectFramework(projectPath, context, wranglerConfig); + + const framework = getFrameworkClassInstance(detectedFramework.framework.id); + const packageJsonPath = resolve(projectPath, "package.json"); + + let packageJson: PackageJSON | undefined; + + try { + packageJson = parsePackageJSON( + readFileSync(packageJsonPath), + packageJsonPath + ); + } catch { + logger.debug("No package.json found when running autoconfig"); + } + + const configured = framework.isConfigured(projectPath) ?? false; + + const outputDir = + detectedFramework?.dist ?? (await findAssetsDir(projectPath)); + + const baseDetails = { + projectPath, + framework, + packageJson, + packageManager, + ...(detectedFramework + ? { + buildCommand: getProjectBuildCommand( + detectedFramework, + packageManager + ), + } + : {}), + workerName: getWorkerName(packageJson?.name, projectPath), + }; + + if (configured) { + return { + ...baseDetails, + configured: true, + isWorkspaceRoot, + }; + } + + if (!outputDir) { + const errorMessage = + framework.id === "static" || framework.id === "cloudflare-pages" + ? "Could not detect a directory containing static files (e.g. html, css and js) for the project" + : "Failed to detect an output directory for the project"; + + throw new AutoConfigDetectionError(errorMessage, { + telemetryMessage: "autoconfig details output directory missing", + frameworkId: framework.id, + configured, + }); + } + + return { + ...baseDetails, + outputDir, + configured: false, + isWorkspaceRoot, + }; +} + +/** + * Given a detected framework this function gets a `build` command for the target project that can be run in the terminal + * (such as `npm run build` or `npx astro build`). If no build command is detected `undefined` is returned instead. + * + * @param detectedFramework The detected framework (or settings) for the project + * @param packageManager The package manager to use for command prefixes + * @returns A runnable command for the build process if detected, undefined otherwise + */ +function getProjectBuildCommand( + detectedFramework: DetectedFramework, + packageManager: PackageManager +): string | undefined { + if (!detectedFramework.buildCommand) { + return undefined; + } + + const { type, dlx, npx } = packageManager; + + for (const packageManagerCommandPrefix of [type, dlx.join(" "), npx]) { + if ( + detectedFramework.buildCommand.startsWith(packageManagerCommandPrefix) + ) { + // The build command already is something like `npm run build` or similar + return detectedFramework.buildCommand; + } + } + + // The command is something like `astro build` so we need to prefix it with `npx` and equivalents + return `${npx} ${detectedFramework.buildCommand}`; +} + +/** + * Displays the detected autoconfig details to the user via the context logger. + * + * @param autoConfigDetails - The detected project details to display. + * @param context - The autoconfig context providing the logger. + * @param displayOptions - Optional display customization. + */ +export function displayAutoConfigDetails( + autoConfigDetails: AutoConfigDetails, + context: AutoConfigContext, + displayOptions?: { heading?: string } +): void { + const { logger } = context; + logger.log(""); + + logger.log(displayOptions?.heading ?? "Detected Project Settings:"); + + logger.log(brandColor(" - Worker Name:"), autoConfigDetails.workerName); + if (autoConfigDetails.framework) { + logger.log(brandColor(" - Framework:"), autoConfigDetails.framework.name); + } + if (autoConfigDetails.buildCommand) { + logger.log(brandColor(" - Build Command:"), autoConfigDetails.buildCommand); + } + if (autoConfigDetails.outputDir) { + logger.log(brandColor(" - Output Directory:"), autoConfigDetails.outputDir); + } + + logger.log(""); +} + +/** + * Prompts the user to confirm or modify the detected autoconfig details. + * + * @param autoConfigDetails - The detected project details. + * @param context - The autoconfig context providing dialogs. + * @returns The (possibly updated) autoconfig details. + */ +export async function confirmAutoConfigDetails( + autoConfigDetails: AutoConfigDetails, + context: AutoConfigContext +): Promise { + const { dialogs } = context; + const modifySettings = await dialogs.confirm( + "Do you want to modify these settings?", + { defaultValue: false, fallbackValue: false } + ); + + if (!modifySettings) { + return autoConfigDetails; + } + + // Just spreading the object to shallow clone it to avoid some potential side effects + const { ...updatedAutoConfigDetails } = autoConfigDetails; + + const workerName = await dialogs.prompt( + "What do you want to name your Worker?", + { + defaultValue: autoConfigDetails.workerName ?? "", + validate: (value: string) => { + const validity = checkWorkerNameValidity(value); + if (validity.valid) { + return true; + } + return validity.cause; + }, + } + ); + + updatedAutoConfigDetails.workerName = workerName; + + const frameworkId = await dialogs.select( + "What framework is your application using?", + { + choices: allFrameworksInfos.map((f) => ({ + title: f.name, + value: f.id, + description: + f.id === staticFramework.id + ? "No framework at all, or a static framework such as Vite, React or Gatsby." + : `The ${f.name} JavaScript framework`, + })), + defaultOption: allFrameworksInfos.findIndex((framework) => { + if (!autoConfigDetails?.framework) { + // If there is no framework already detected let's default to the static one + // (note: there should always be a framework at this point) + return framework.id === staticFramework.id; + } + return autoConfigDetails.framework.id === framework.id; + }), + } + ); + + updatedAutoConfigDetails.framework = getFrameworkClassInstance(frameworkId); + + const outputDir = await dialogs.prompt( + "What directory contains your applications' output/asset files?", + { + defaultValue: autoConfigDetails.outputDir ?? "", + validate: async (value) => { + if (!value) { + return "Please provide a valid directory path"; + } + const valueStats = statSync(resolve(value), { throwIfNoEntry: false }); + if (!valueStats) { + // If the path doesn't point to anything that's fine since the directory will likely be + // generated by the build command anyways + return true; + } + if (valueStats?.isFile()) { + return "Please select a directory"; + } + return true; + }, + } + ); + + updatedAutoConfigDetails.outputDir = outputDir; + + if (autoConfigDetails.buildCommand || autoConfigDetails.packageJson) { + const buildCommand = await dialogs.prompt( + "What is your application's build command?", + { + defaultValue: autoConfigDetails.buildCommand ?? "", + } + ); + + updatedAutoConfigDetails.buildCommand = buildCommand; + } + + return updatedAutoConfigDetails; +} diff --git a/packages/autoconfig/src/errors.ts b/packages/autoconfig/src/errors.ts new file mode 100644 index 0000000..2bbb4cb --- /dev/null +++ b/packages/autoconfig/src/errors.ts @@ -0,0 +1,39 @@ +import { FatalError, UserError } from "@cloudflare/workers-utils"; +import type { TelemetryMessage } from "@cloudflare/workers-utils"; + +/** + * Base class for errors where something in a autoconfig frameworks' configuration goes + * something wrong. These are not reported to Sentry. + */ +export class AutoConfigFrameworkConfigurationError extends UserError {} + +/** + * Error thrown when autoconfig detection fails. + * Carries detection metadata (`frameworkId`, `configured`) so that callers can + * extract it for telemetry without the autoconfig library needing to know about + * the telemetry system. + */ +export class AutoConfigDetectionError extends FatalError { + /** The detected framework identifier (if detection got far enough to determine it). */ + readonly frameworkId: string | undefined; + /** Whether the project was already configured at the time of the error. */ + readonly configured: boolean; + + /** + * @param message - The human-readable error message. + * @param options - Error options including telemetry message, optional code, and detection metadata. + */ + constructor( + message: string, + options: TelemetryMessage & { + code?: number; + frameworkId?: string; + configured: boolean; + } + ) { + super(message, options); + Object.setPrototypeOf(this, new.target.prototype); + this.frameworkId = options.frameworkId; + this.configured = options.configured; + } +} diff --git a/packages/autoconfig/src/frameworks/all-frameworks.ts b/packages/autoconfig/src/frameworks/all-frameworks.ts new file mode 100644 index 0000000..7770d9f --- /dev/null +++ b/packages/autoconfig/src/frameworks/all-frameworks.ts @@ -0,0 +1,276 @@ +import assert from "node:assert"; +import { Analog } from "./analog"; +import { Angular } from "./angular"; +import { Astro } from "./astro"; +import { NextJs } from "./next"; +import { Nuxt } from "./nuxt"; +import { Qwik } from "./qwik"; +import { ReactRouter } from "./react-router"; +import { SolidStart } from "./solid-start"; +import { Static } from "./static"; +import { SvelteKit } from "./sveltekit"; +import { TanstackStart } from "./tanstack"; +import { Vike } from "./vike"; +import { Vite } from "./vite"; +import { Waku } from "./waku"; +import type { AutoConfigFrameworkPackageInfo, FrameworkInfo } from "."; + +/** + * Information about all the known frameworks, including frameworks that we know about but we don't support. + * + * The "static" framework is not included in this list. + */ +export const allKnownFrameworks = [ + { + id: "analog", + name: "Analog", + class: Analog, + frameworkPackageInfo: { + name: "@analogjs/platform", + // Analog didn't work well before 2.0.0 with Cloudflare + // See: https://github.com/cloudflare/workers-sdk/issues/11470 + minimumVersion: "2.0.0", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "angular", + name: "Angular", + class: Angular, + frameworkPackageInfo: { + name: "@angular/core", + // Angular 19 introduced ssr.experimentalPlatform and AngularAppEngine + // which are required for Cloudflare Workers support + // See: https://github.com/angular/angular-cli/releases/tag/19.0.0 + // Angular 22 renamed experimentalPlatform to platform + // See: https://github.com/angular/angular-cli/commit/af2c7e9444fba81d3b1fd2d37dc4412f8305b5ed + minimumVersion: "19.0.0", + maximumKnownMajorVersion: "22", + }, + supported: true, + }, + { + id: "astro", + name: "Astro", + class: Astro, + frameworkPackageInfo: { + name: "astro", + // Version 4 was the earliest version that we manually tested + // in https://github.com/cloudflare/workers-sdk/pull/12938 + // earlier versions might also be supported but we haven't checked them + minimumVersion: "4.0.0", + maximumKnownMajorVersion: "6", + }, + supported: true, + }, + { + id: "hono", + name: "Hono", + supported: false, + }, + { + id: "next", + name: "Next.js", + class: NextJs, + frameworkPackageInfo: { + name: "next", + // 14.2.35 is the earliest version of Next.js officially supported by open-next + // see: https://github.com/cloudflare/workers-sdk/pull/11704#discussion_r2634519440 + minimumVersion: "14.2.35", + maximumKnownMajorVersion: "16", + }, + supported: true, + }, + { + id: "nuxt", + name: "Nuxt", + class: Nuxt, + frameworkPackageInfo: { + name: "nuxt", + // 3.21.0 is the first Nuxt version with Nitro 2.11+ which supports + // cloudflare.deployConfig and cloudflare.nodeCompat options + // See: https://github.com/nuxt/nuxt/releases/tag/v3.21.0 + // See: https://github.com/nitrojs/nitro/releases/tag/v2.11.0 + minimumVersion: "3.21.0", + maximumKnownMajorVersion: "4", + }, + supported: true, + }, + { + id: "qwik", + name: "Qwik", + class: Qwik, + frameworkPackageInfo: { + name: "@builder.io/qwik", + // 1.1.0 added the `platform` option in the qwikCity() Vite plugin + // which is required for getPlatformProxy integration + // See: https://github.com/QwikDev/qwik/pull/3604 + minimumVersion: "1.1.0", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "react-router", + name: "React Router", + class: ReactRouter, + frameworkPackageInfo: { + name: "react-router", + // React Router v7 introduced framework mode with Vite integration and + // react-router.config.ts which are required for Cloudflare Workers support + // See: https://remix.run/blog/react-router-v7 + minimumVersion: "7.0.0", + maximumKnownMajorVersion: "8", + }, + supported: true, + }, + { + id: "solid-start", + name: "Solid Start", + class: SolidStart, + frameworkPackageInfo: { + name: "@solidjs/start", + // 1.0.0 is the first stable release with Nitro/Cloudflare Workers support + // See: https://github.com/solidjs/solid-start/releases/tag/v1.0.0 + minimumVersion: "1.0.0", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "svelte-kit", + name: "SvelteKit", + class: SvelteKit, + frameworkPackageInfo: { + name: "@sveltejs/kit", + // 2.20.3 is required by @sveltejs/adapter-cloudflare@7.0.0 which first + // added Workers Static Assets support (cfTarget:workers option) + // See: https://github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fadapter-cloudflare%407.0.0 + minimumVersion: "2.20.3", + maximumKnownMajorVersion: "2", + }, + supported: true, + }, + { + id: "tanstack-start", + name: "TanStack Start", + class: TanstackStart, + frameworkPackageInfo: { + name: "@tanstack/react-start", + // 1.132.0 is the first Release Candidate for TanStack Start that supports Cloudflare + // See: https://github.com/TanStack/router/releases/tag/v1.132.0 + minimumVersion: "1.132.0", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "vite", + name: "Vite", + class: Vite, + frameworkPackageInfo: { + name: "vite", + // Vite 6 introduced the Environment API which @cloudflare/vite-plugin requires + // See: https://vite.dev/blog/announcing-vite6#experimental-environment-api + // (6.1.0 is the minimum version supported by the vite plugin: + // https://github.com/cloudflare/workers-sdk/blob/b9b7e9d9fe/packages/vite-plugin-cloudflare/package.json#L80 + // we anyways allow for `6.0.x` versions since we bump them to `^6.1.0` in the autoconfig process) + minimumVersion: "6.0.0", + maximumKnownMajorVersion: "8", + }, + supported: true, + }, + { + id: "vike", + name: "Vike", + class: Vike, + frameworkPackageInfo: { + name: "vike", + minimumVersion: "0.0.0", + maximumKnownMajorVersion: "0", + }, + supported: true, + }, + { + id: "waku", + name: "Waku", + class: Waku, + frameworkPackageInfo: { + name: "waku", + // Autoconfig could support Waku before 1.0.0-alpha.4, but different autoconfig logic + // would need to be implemented for such versions, so we just decided to only support + // version 1.0.0-alpha.4 and up + // See: https://github.com/cloudflare/workers-sdk/pull/12657 + minimumVersion: "1.0.0-alpha.4", + maximumKnownMajorVersion: "1", + }, + supported: true, + }, + { + id: "cloudflare-pages", + name: "Cloudflare Pages", + // Autoconfiguring a Pages project into a Workers one is not yet supported + supported: false, + }, + { + id: "hydrogen", + name: "Hydrogen", + supported: false, + }, +] as const satisfies FrameworkInfo[]; + +/** + * Type specific for the "static" framework. + * + * It is supported by autoconfig but, unlike all other frameworks, it doesn't have a package associated to it + */ +type StaticFrameworkInfo = Omit< + Extract, + "frameworkPackageInfo" +>; + +export const staticFramework = { + id: "static", + name: "Static", + class: Static, + supported: true, +} as const satisfies StaticFrameworkInfo; + +/** Information for all the possible frameworks. This includes the "static" framework */ +export const allFrameworksInfos = [ + staticFramework, + ...allKnownFrameworks, +] as const satisfies (FrameworkInfo | StaticFrameworkInfo)[]; + +/** + * Gets the package information for a given framework, erroring if the framework + * could not be determined or is not supported. + * + * Returns `undefined` for the "static" framework, which has no associated package. + * + * @param frameworkId The id of the target framework + * @returns The framework's target package info, or undefined if the framework is "static" + */ +export function getFrameworkPackageInfo( + frameworkId: FrameworkInfo["id"] +): AutoConfigFrameworkPackageInfo | undefined { + if (frameworkId === staticFramework.id) { + // The "static" framework does not have an associated package + return undefined; + } + const targetedFramework = allKnownFrameworks.find( + (framework) => framework.id === frameworkId + ); + assert( + targetedFramework, + `Could not determine framework package info for ${JSON.stringify( + frameworkId + )}` + ); + assert( + targetedFramework.supported, + `Framework unexpectedly not supported ${JSON.stringify(frameworkId)}` + ); + return targetedFramework.frameworkPackageInfo; +} diff --git a/packages/autoconfig/src/frameworks/analog.ts b/packages/autoconfig/src/frameworks/analog.ts new file mode 100644 index 0000000..19faef8 --- /dev/null +++ b/packages/autoconfig/src/frameworks/analog.ts @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { blue } from "@cloudflare/cli-shared-helpers/colors"; +import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; +import { getTodaysCompatDate } from "@cloudflare/workers-utils"; +import * as recast from "recast"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class Analog extends Framework { + async configure({ + dryRun, + projectPath, + }: ConfigurationOptions): Promise { + if (!dryRun) { + await updateViteConfig(projectPath); + } + + return { + wranglerConfig: { + main: "./dist/analog/server/index.mjs", + assets: { + binding: "ASSETS", + directory: "./dist/analog/public", + }, + }, + }; + } +} + +async function updateViteConfig(projectPath: string) { + const viteConfigTsPAth = join(projectPath, "vite.config.ts"); + const viteConfigJsPath = join(projectPath, "vite.config.js"); + + let viteConfigPath: string; + + if (existsSync(viteConfigTsPAth)) { + viteConfigPath = viteConfigTsPAth; + } else if (existsSync(viteConfigJsPath)) { + viteConfigPath = viteConfigJsPath; + } else { + throw new Error("Could not find Vite config file to modify"); + } + + const compatDate = getTodaysCompatDate(); + + updateStatus(`Updating configuration in ${blue(viteConfigPath)}`); + + transformFile(viteConfigPath, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "analog") { + return this.traverse(n); + } + + const b = recast.types.builders; + const presetDef = [ + b.objectProperty( + b.identifier("nitro"), + b.objectExpression([ + // preset: "cloudflare_module" + b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare_module") + ), + b.objectProperty( + b.identifier("compatibilityDate"), + b.stringLiteral(compatDate) + ), + ]) + ), + ]; + + if (n.node.arguments.length === 0) { + n.node.arguments.push(b.objectExpression(presetDef)); + } else { + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + presetDef + ); + } + + return false; + }, + }); +} diff --git a/packages/autoconfig/src/frameworks/angular.ts b/packages/autoconfig/src/frameworks/angular.ts new file mode 100644 index 0000000..fdebfb1 --- /dev/null +++ b/packages/autoconfig/src/frameworks/angular.ts @@ -0,0 +1,155 @@ +import assert from "node:assert"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { parseJSONC } from "@cloudflare/workers-utils"; +import semiver from "semiver"; +import dedent from "ts-dedent"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { PackageManager } from "@cloudflare/workers-utils"; + +export class Angular extends Framework { + async configure({ + workerName, + outputDir, + dryRun, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + const angularJson = parseJSONC( + await readFile(resolve("angular.json"), "utf8") + ) as AngularJson; + + if (hasSsr(angularJson, workerName)) { + this.configurationDescription = "Configuring project for Angular"; + if (!dryRun) { + await updateAngularJson(workerName, angularJson, this.frameworkVersion); + await overrideServerFile(); + await installAdditionalDependencies(packageManager, isWorkspaceRoot); + } + return { + wranglerConfig: { + main: "./dist/server/server.mjs", + assets: { + binding: "ASSETS", + directory: `${outputDir}browser`, + }, + }, + }; + } else { + this.configurationDescription = + "Configuring Angular SPA project (assets only)"; + return { + wranglerConfig: { + assets: { + directory: outputDir, + }, + }, + }; + } + } +} + +/** + * Checks whether the given Angular project has SSR configured. + * SSR is considered enabled when `architect.build.options.ssr` exists and is truthy. + */ +function hasSsr(angularJson: AngularJson, projectName: string): boolean { + const ssr = angularJson.projects[projectName]?.architect?.build?.options?.ssr; + return !!ssr; +} + +async function updateAngularJson( + projectName: string, + angularJson: AngularJson, + frameworkVersion: string +) { + const s = spinner(); + s.start(`Updating angular.json config`); + + // Update builder + const architectSection = angularJson.projects[projectName].architect; + architectSection.build.options.outputPath = "dist"; + architectSection.build.options.outputMode = "server"; + // ssr is guaranteed to be truthy here (checked by hasSsr before calling this). + // `ssr: true` is a valid Angular shorthand for SSR with defaults — normalise it + // to an object before setting properties on it. + assert(architectSection.build.options.ssr); + if (typeof architectSection.build.options.ssr === "boolean") { + architectSection.build.options.ssr = {}; + } + const platformKey = + semiver(frameworkVersion, "22.0.0") >= 0 + ? "platform" + : "experimentalPlatform"; + architectSection.build.options.ssr[platformKey] = "neutral"; + + await writeFile( + resolve("angular.json"), + JSON.stringify(angularJson, null, 2) + ); + + s.stop(`${brandColor(`updated`)} ${dim(`\`angular.json\``)}`); +} + +async function overrideServerFile() { + await writeFile( + resolve("src/server.ts"), + dedent` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + + const angularApp = new AngularAppEngine({ + // It is safe to set allow \`localhost\`, so that SSR can run in local development, + // as, in production, Cloudflare will ensure that \`localhost\` is not the host. + allowedHosts: ['localhost'], + }); + + /** + * This is a request handler used by the Angular CLI (dev-server and during build). + */ + export const reqHandler = createRequestHandler(async (req) => { + const res = await angularApp.handle(req); + + return res ?? new Response('Page not found.', { status: 404 }); + }); + + export default { fetch: reqHandler }; + ` + ); +} + +async function installAdditionalDependencies( + packageManager: PackageManager, + isWorkspaceRoot: boolean +) { + await installPackages(packageManager.type, ["xhr2"], { + dev: true, + startText: "Installing additional dependencies", + doneText: `${brandColor("installed")}`, + isWorkspaceRoot, + }); +} + +type AngularJson = { + projects: Record< + string, + { + architect: { + build: { + options: { + outputPath: string; + outputMode: string; + ssr?: Record | boolean | null; + assets: string[]; + }; + }; + }; + } + >; +}; diff --git a/packages/autoconfig/src/frameworks/astro.ts b/packages/autoconfig/src/frameworks/astro.ts new file mode 100644 index 0000000..cbc986f --- /dev/null +++ b/packages/autoconfig/src/frameworks/astro.ts @@ -0,0 +1,305 @@ +import { + existsSync, + readFileSync as fsReadFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { blue, brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; +import { parseJSONC } from "@cloudflare/workers-utils"; +import * as recast from "recast"; +import semiver from "semiver"; +import { Framework } from "./framework-class"; +import type { AutoConfigContext } from "../context"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { PackageManager } from "@cloudflare/workers-utils"; + +export class Astro extends Framework { + async configure({ + outputDir, + dryRun, + packageManager, + projectPath, + isWorkspaceRoot, + context, + }: ConfigurationOptions): Promise { + const astroVersion = this.frameworkVersion; + + const { npx } = packageManager; + if (!dryRun) { + if (semiver(astroVersion, "6.0.0") >= 0) { + // For Astro 6.0.0+ use the native `astro add cloudflare` command + await runCommand([npx, "astro", "add", "cloudflare", "-y"], { + silent: true, + startText: "Installing adapter", + doneText: `${brandColor("installed")} ${dim( + `via \`${npx} astro add cloudflare\`` + )}`, + }); + } else { + // For older versions of Astro we need to apply manual configuration since `astro add cloudflare` + // tries to install the latest version of the adapter causing conflicts + + // Note: here the Astro version can only be 5 or 4 because of the minimum version validation + const astroMajorVersion = semiver(astroVersion, "5.0.0") >= 0 ? 5 : 4; + await configureAstroLegacy( + projectPath, + isWorkspaceRoot, + packageManager, + astroMajorVersion, + context + ); + } + + writeFileSync("public/.assetsignore", "_worker.js\n_routes.json"); + } + + if (semiver(astroVersion, "6.0.0") < 0) { + // Before version 6 Astro required a wrangler config file + return { + wranglerConfig: { + main: `${outputDir}/_worker.js/index.js`, + compatibility_flags: ["global_fetch_strictly_public"], + assets: { + binding: "ASSETS", + directory: outputDir, + }, + }, + }; + } + + // From version 6 Astro doesn't need a wrangler config file but generates a redirected config on build + return { + wranglerConfig: null, + }; + } + + configurationDescription = + 'Configuring project for Astro with "astro add cloudflare"'; +} + +/** + * Finds the Astro config file in the project directory. + * Checks for astro.config.mjs, astro.config.ts, and astro.config.js in that order. + * + * @param projectPath The path of the project + * @returns The path to the Astro config file + * @throws Error if no config file is found + */ +function findAstroConfigFile(projectPath: string): string { + const extensions = ["mjs", "mts", "ts", "js"]; + for (const ext of extensions) { + const configPath = join(projectPath, `astro.config.${ext}`); + if (existsSync(configPath)) { + return configPath; + } + } + throw new Error( + "Could not find Astro config file (astro.config.mjs, astro.config.mts, astro.config.ts, or astro.config.js)" + ); +} + +/** + * Updates the Astro config file to add the Cloudflare adapter. + * This replicates the logic from `astro add cloudflare` for Astro versions < 6.0.0. + * + * @param projectPath The path of the project + * @param astroMajorVersion The major version of Astro (4 or 5) to determine the config options + */ +function updateAstroConfig( + projectPath: string, + astroMajorVersion: 4 | 5 +): void { + const configPath = findAstroConfigFile(projectPath); + + updateStatus(`Updating configuration in ${blue(configPath)}`); + + // Track the adapter identifier name (either from existing import or the one we add) + let adapterIdentifier = "cloudflare"; + + transformFile(configPath, { + // First pass: check for existing cloudflare import and add if missing + visitProgram(path) { + const body = path.node.body; + const b = recast.types.builders; + + // Check if cloudflare import already exists and capture the local identifier + let hasCloudflareImport = false; + for (const node of body) { + if ( + node.type === "ImportDeclaration" && + node.source.value === "@astrojs/cloudflare" + ) { + hasCloudflareImport = true; + // Find the default import specifier and capture its local name + for (const specifier of node.specifiers ?? []) { + if ( + specifier.type === "ImportDefaultSpecifier" && + specifier.local + ) { + // specifier.local is an Identifier node with a `name` property + const local = specifier.local as { name: string }; + adapterIdentifier = local.name; + break; + } + } + break; + } + } + + // Add the import if it doesn't exist + if (!hasCloudflareImport) { + const importDeclaration = b.importDeclaration( + [b.importDefaultSpecifier(b.identifier("cloudflare"))], + b.literal("@astrojs/cloudflare") + ); + + // Find the last import statement and insert after it + let lastImportIndex = -1; + for (let i = 0; i < body.length; i++) { + if (body[i].type === "ImportDeclaration") { + lastImportIndex = i; + } + } + + if (lastImportIndex >= 0) { + body.splice(lastImportIndex + 1, 0, importDeclaration); + } else { + // No imports found, add at the beginning + body.unshift(importDeclaration); + } + } + + this.traverse(path); + }, + + // Second pass: add adapter (and output for Astro 4) to defineConfig + visitCallExpression(path) { + const callee = path.node.callee; + + // Check if this is a defineConfig call + if (callee.type !== "Identifier" || callee.name !== "defineConfig") { + return this.traverse(path); + } + + const b = recast.types.builders; + + // Create the adapter property using the captured identifier name + const adapterProp = b.objectProperty( + b.identifier("adapter"), + b.callExpression(b.identifier(adapterIdentifier), []) + ); + + const propsToAdd: recast.types.namedTypes.ObjectProperty[] = []; + + if (astroMajorVersion === 4) { + // Astro 4 requires explicit output: "hybrid" for SSR + const outputProp = b.objectProperty( + b.identifier("output"), + b.literal("hybrid") + ); + propsToAdd.push(outputProp); + } + propsToAdd.push(adapterProp); + + // Get or create the config object argument + if (path.node.arguments.length === 0) { + path.node.arguments.push(b.objectExpression(propsToAdd)); + } else { + const configArg = path.node + .arguments[0] as recast.types.namedTypes.ObjectExpression; + if (configArg.type === "ObjectExpression") { + mergeObjectProperties(configArg, propsToAdd); + } + } + + return false; + }, + }); +} + +/** + * Updates the tsconfig.json to include worker-configuration.d.ts. + * This replicates part of the `astro add cloudflare` behavior. + * + * @param projectPath The path of the project + * @param context The autoconfig context providing logger and other dependencies + */ +function updateTsConfig(projectPath: string, context: AutoConfigContext) { + const tsconfigPath = join(projectPath, "tsconfig.json"); + if (!existsSync(tsconfigPath)) { + return; + } + + try { + const content = fsReadFileSync(tsconfigPath, "utf-8"); + const tsconfig = parseJSONC(content, tsconfigPath) as Record< + string, + unknown + >; + + const includeEntry = "./worker-configuration.d.ts"; + + if (!tsconfig.include) { + // If `include` is not defined, the tsconfig likely inherits it from a parent config (e.g., "extends": "astro/tsconfigs/base"). + // Adding an `include` field here would override the parent's includes, breaking type-checking. + // Instead, warn the user to add it manually. + context.logger.warn( + `Could not find an existing \`include\` field in tsconfig.json. You may need to manually add ${JSON.stringify( + includeEntry + )} to your tsconfig.json \`include\` array.` + ); + return; + } + + if (!(tsconfig.include as string[]).includes(includeEntry)) { + (tsconfig.include as string[]).push(includeEntry); + writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, "\t")); + updateStatus( + `Updated ${blue("tsconfig.json")} to include ${blue(includeEntry)}` + ); + } + } catch { + context.logger.warn( + `Could not update tsconfig.json to include worker-configuration.d.ts. You may need to add it manually.` + ); + } +} + +/** + * Configures an Astro project for Cloudflare deployment when running Astro < 6.0.0. + * This replicates the core logic from `astro add cloudflare` command since that command + * is not available or behaves differently in older Astro versions. + * + * @param projectPath The path of the project + * @param isWorkspaceRoot Whether the project is at the root of a workspace (affects package installation flags) + * @param packageManager The package manager to use for installing dependencies + * @param astroMajorVersion The major version of Astro (4 or 5) to determine the correct adapter version + */ +async function configureAstroLegacy( + projectPath: string, + isWorkspaceRoot: boolean, + packageManager: PackageManager, + astroMajorVersion: 4 | 5, + context: AutoConfigContext +): Promise { + const astroCloudflarePackageVersion = astroMajorVersion === 5 ? 12 : 11; + + await installPackages( + packageManager.type, + [`@astrojs/cloudflare@${astroCloudflarePackageVersion}`], + { + startText: `Installing @astrojs/cloudflare adapter (version ${astroCloudflarePackageVersion})`, + doneText: `${brandColor("installed")} ${dim("@astrojs/cloudflare")}`, + isWorkspaceRoot, + } + ); + updateAstroConfig(projectPath, astroMajorVersion); + updateTsConfig(projectPath, context); +} diff --git a/packages/autoconfig/src/frameworks/framework-class.ts b/packages/autoconfig/src/frameworks/framework-class.ts new file mode 100644 index 0000000..93f5152 --- /dev/null +++ b/packages/autoconfig/src/frameworks/framework-class.ts @@ -0,0 +1,119 @@ +import assert from "node:assert"; +import semiver from "semiver"; +import { AutoConfigFrameworkConfigurationError } from "../errors"; +import { getInstalledPackageVersion } from "./utils/packages"; +import type { AutoConfigFrameworkPackageInfo, FrameworkInfo } from "."; +import type { AutoConfigContext } from "../context"; +import type { PackageManager } from "@cloudflare/workers-utils"; +import type { RawConfig } from "@cloudflare/workers-utils"; + +export abstract class Framework { + readonly id: FrameworkInfo["id"]; + readonly name: FrameworkInfo["name"]; + + #frameworkVersion: string | undefined; + get frameworkVersion(): string { + assert( + this.#frameworkVersion, + `The version for ${JSON.stringify(this.name)} is unexpectedly missing` + ); + return this.#frameworkVersion; + } + + constructor(frameworkInfo: Pick) { + this.id = frameworkInfo.id; + this.name = frameworkInfo.name; + } + + isConfigured(_projectPath: string): boolean { + return false; + } + + abstract configure( + options: ConfigurationOptions + ): Promise | ConfigurationResults; + + configurationDescription?: string; + + /** + * Validates the installed framework version against the supported range and + * stores it for later access via the `frameworkVersion` getter. + * Warns via the context logger if the version exceeds `maximumKnownMajorVersion`. + * + * @param projectPath - Path to the project root used to resolve the installed version. + * @param frameworkPackageInfo - Package metadata including name and version bounds. + * @param context - The autoconfig context providing logger and other dependencies. + * @throws {AssertionError} If the installed version cannot be determined. + * @throws {AutoConfigFrameworkConfigurationError} If the version is below `minimumVersion`. + */ + validateFrameworkVersion( + projectPath: string, + frameworkPackageInfo: AutoConfigFrameworkPackageInfo, + context: AutoConfigContext + ) { + const frameworkVersion = getInstalledPackageVersion( + frameworkPackageInfo.name, + projectPath + ); + + assert( + frameworkVersion, + `Unable to detect the version of the \`${frameworkPackageInfo.name}\` package` + ); + + if (semiver(frameworkVersion, frameworkPackageInfo.minimumVersion) < 0) { + throw new AutoConfigFrameworkConfigurationError( + `The version of ${this.name} used in the project (${JSON.stringify( + frameworkVersion + )}) cannot be automatically configured. Please update the ${ + this.name + } version to at least ${JSON.stringify( + frameworkPackageInfo.minimumVersion + )} and try again.`, + { telemetryMessage: "autoconfig framework version unsupported" } + ); + } + + if ( + semiver(frameworkVersion, frameworkPackageInfo.maximumKnownMajorVersion) > + 0 + ) { + context.logger.warn( + `The version of ${this.name} used in the project (${JSON.stringify( + frameworkVersion + )}) is not officially supported, and may fail to correctly configure. Please report any issues to https://github.com/cloudflare/workers-sdk/issues` + ); + } + + this.#frameworkVersion = frameworkVersion; + } +} + +export type ConfigurationOptions = { + outputDir: string; + projectPath: string; + workerName: string; + dryRun: boolean; + packageManager: PackageManager; + isWorkspaceRoot: boolean; + context: AutoConfigContext; +}; + +export type PackageJsonScriptsOverrides = { + preview?: string; // default is `npm run build && wrangler dev` + deploy?: string; // default is `npm run build && wrangler deploy` + typegen?: string; // default is `wrangler types` +}; + +export type ConfigurationResults = { + /** The wrangler configuration that the framework's `configure()` hook should generate. `null` if autoconfig should not create the wrangler file (in case an external tool already does that) */ + wranglerConfig: RawConfig | null; + // Scripts to override in the package.json. Most frameworks should not need to do this, as their default detected build command will be sufficient + packageJsonScriptsOverrides?: PackageJsonScriptsOverrides; + // Build command to override the standard one (`npm run build` or framework's build command) + buildCommandOverride?: string; + // Deploy command to override the standard one (`npx wrangler deploy`) + deployCommandOverride?: string; + // Version command to override the standard one (`npx wrangler versions upload`) + versionCommandOverride?: string; +}; diff --git a/packages/autoconfig/src/frameworks/index.ts b/packages/autoconfig/src/frameworks/index.ts new file mode 100644 index 0000000..9e90c2a --- /dev/null +++ b/packages/autoconfig/src/frameworks/index.ts @@ -0,0 +1,83 @@ +import assert from "node:assert"; +import { allFrameworksInfos, staticFramework } from "./all-frameworks"; +import { NoOpFramework } from "./no-op"; +import type { Framework } from "./framework-class"; + +export type { Framework, PackageJsonScriptsOverrides } from "./framework-class"; + +/** Set of the ids of all the possible frameworks, including the "static" framework */ +const allKnownFrameworksIds = new Set( + allFrameworksInfos.map(({ id }) => id) +); + +/** + * Identifies whether a given id maps to a known framework's id + * + * @param frameworkId The target id to check + * @returns true if the id is that of a known framework, false otherwise + */ +export function isKnownFramework(frameworkId: string): boolean { + return allKnownFrameworksIds.has(frameworkId); +} + +/** + * Gets a class instance for a framework based on its id + * + * @param frameworkId The target framework's id + * @returns The class for the framework, defaulting to the static framework is the id is not recognized + */ +export function getFrameworkClassInstance( + frameworkId: FrameworkInfo["id"] +): Framework { + const targetedFramework = allFrameworksInfos.find( + (framework) => framework.id === frameworkId + ); + const framework = targetedFramework ?? staticFramework; + const targetClass = + framework.supported === false ? NoOpFramework : framework.class; + + return new targetClass({ id: framework.id, name: framework.name }); +} + +/** + * Checks whether a framework is supported by autoconfig. + * + * @param frameworkId The target framework's id + * @returns a boolean indicating wether the framework is supported + */ +export function isFrameworkSupported( + frameworkId: FrameworkInfo["id"] +): boolean { + const targetedFramework = allFrameworksInfos.find( + (framework) => framework.id === frameworkId + ); + assert( + targetedFramework, + `Unexpected unknown framework id: ${JSON.stringify(frameworkId)}` + ); + return targetedFramework.supported; +} + +export type FrameworkInfo = { + id: string; + name: string; +} & ( + | { supported: false } + | { + supported: true; + class: typeof Framework; + frameworkPackageInfo: AutoConfigFrameworkPackageInfo; + } +); + +/** + * AutoConfig information for a package that defines a framework. + */ +export type AutoConfigFrameworkPackageInfo = { + /** The package name (e.g. "astro" for the Astro framework and "@solidjs/start" for the SolidStart framework) */ + name: string; + /** The minimum version (if any) of the package/framework that autoconfig supports */ + minimumVersion: string; + /** The latest major version of the package/framework that autoconfig supports */ + maximumKnownMajorVersion: string; +}; diff --git a/packages/autoconfig/src/frameworks/next.ts b/packages/autoconfig/src/frameworks/next.ts new file mode 100644 index 0000000..70af217 --- /dev/null +++ b/packages/autoconfig/src/frameworks/next.ts @@ -0,0 +1,48 @@ +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class NextJs extends Framework { + async configure({ + dryRun, + projectPath, + packageManager, + }: ConfigurationOptions): Promise { + const { npx, dlx } = packageManager; + + if (!dryRun) { + await runCommand( + [ + ...dlx, + "@opennextjs/cloudflare", + "migrate", + // Note: we force-install so that even if an incompatible version of + // Next.js is used this installation still succeeds, moving users + // (hopefully) in right direction (instead of failing at this step) + "--force-install", + ], + { + cwd: projectPath, + } + ); + } + + return { + // `@opennextjs/cloudflare migrate` creates the wrangler config file + wranglerConfig: {}, + packageJsonScriptsOverrides: { + preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview", + deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + }, + buildCommandOverride: `${npx} opennextjs-cloudflare build`, + deployCommandOverride: `${npx} opennextjs-cloudflare deploy`, + versionCommandOverride: `${npx} opennextjs-cloudflare upload`, + }; + } + + configurationDescription = + "Configuring project for Next.js with OpenNext by running `@opennextjs/cloudflare migrate`"; +} diff --git a/packages/autoconfig/src/frameworks/no-op.ts b/packages/autoconfig/src/frameworks/no-op.ts new file mode 100644 index 0000000..4746451 --- /dev/null +++ b/packages/autoconfig/src/frameworks/no-op.ts @@ -0,0 +1,10 @@ +import { Framework } from "./framework-class"; +import type { ConfigurationResults } from "./framework-class"; + +export class NoOpFramework extends Framework { + async configure(): Promise { + return { + wranglerConfig: {}, + }; + } +} diff --git a/packages/autoconfig/src/frameworks/nuxt.ts b/packages/autoconfig/src/frameworks/nuxt.ts new file mode 100644 index 0000000..4720ea3 --- /dev/null +++ b/packages/autoconfig/src/frameworks/nuxt.ts @@ -0,0 +1,89 @@ +import path from "node:path"; +import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; +import * as recast from "recast"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +const updateNuxtConfig = (projectPath: string) => { + const configFile = path.join(projectPath, "nuxt.config.ts"); + + const b = recast.types.builders; + + const presetDef = b.objectProperty( + b.identifier("nitro"), + b.objectExpression([ + b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare_module") + ), + b.objectProperty( + b.identifier("cloudflare"), + b.objectExpression([ + b.objectProperty( + b.identifier("deployConfig"), + b.booleanLiteral(true) + ), + b.objectProperty(b.identifier("nodeCompat"), b.booleanLiteral(true)), + ]) + ), + ]) + ); + + const moduleDef = b.objectProperty( + b.identifier("modules"), + b.arrayExpression([b.stringLiteral("nitro-cloudflare-dev")]) + ); + + transformFile(configFile, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name === "defineNuxtConfig") { + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + [presetDef, moduleDef] + ); + } + + return this.traverse(n); + }, + }); +}; + +export class Nuxt extends Framework { + async configure({ + dryRun, + projectPath, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + if (!dryRun) { + await installPackages(packageManager.type, ["nitro-cloudflare-dev"], { + dev: true, + startText: "Installing the Cloudflare dev module", + doneText: `${brandColor(`installed`)} ${dim("nitro-cloudflare-dev")}`, + isWorkspaceRoot, + }); + updateNuxtConfig(projectPath); + } + + return { + wranglerConfig: { + main: "./.output/server/index.mjs", + assets: { + binding: "ASSETS", + directory: "./.output/public/", + }, + observability: { + enabled: true, + }, + }, + }; + } + + configurationDescription = "Configuring project for Nuxt"; +} diff --git a/packages/autoconfig/src/frameworks/qwik.ts b/packages/autoconfig/src/frameworks/qwik.ts new file mode 100644 index 0000000..698ec69 --- /dev/null +++ b/packages/autoconfig/src/frameworks/qwik.ts @@ -0,0 +1,135 @@ +import { endSection } from "@cloudflare/cli-shared-helpers"; +import { brandColor } from "@cloudflare/cli-shared-helpers/colors"; +import { + quoteShellArgs, + runCommand, +} from "@cloudflare/cli-shared-helpers/command"; +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; +import { transformFile } from "@cloudflare/codemod"; +import * as recast from "recast"; +import * as typescriptParser from "recast/parsers/typescript"; +import { usesTypescript } from "../uses-typescript"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { Program } from "esprima"; + +export class Qwik extends Framework { + async configure({ + projectPath, + dryRun, + packageManager, + }: ConfigurationOptions): Promise { + if (!dryRun) { + // Add the workers integration + const cmd = [ + // For some reason `pnpx qwik add` fails for qwik so we use `pnpm qwik add` instead. + packageManager.type === "pnpm" + ? packageManager.type + : packageManager.npx, + "qwik", + "add", + "cloudflare-workers", + "--skipConfirmation=true", + ]; + endSection(`Running ${quoteShellArgs(cmd)}`); + await runCommand(cmd); + + addBindingsProxy(projectPath); + } + return { + wranglerConfig: { + main: "./dist/_worker.js", + compatibility_flags: ["global_fetch_strictly_public"], + assets: { + binding: "ASSET", + directory: "./dist", + }, + }, + packageJsonScriptsOverrides: { + preview: `${packageManager.type} run build && wrangler dev`, + deploy: `${packageManager.type} run build && wrangler deploy`, + }, + }; + } + + configurationDescription = + 'Configuring project for Qwik with "qwik add cloudflare-workers"'; +} + +function addBindingsProxy(projectPath: string) { + // Qwik only has a typescript template atm. + // This check is an extra precaution + if (!usesTypescript(projectPath)) { + return; + } + + const s = spinner(); + s.start("Updating `vite.config.ts`"); + + const b = recast.types.builders; + + const getPlatformProxyTsAstNodes = ( + recast.parse( + ` + let platform = {}; + + if (process.env.NODE_ENV === 'development') { + const { getPlatformProxy } = await import('wrangler'); + platform = await getPlatformProxy(); + } + `, + { parser: typescriptParser } + ).program as Program + ).body; + + transformFile("vite.config.ts", { + // Insert the env declaration after the last import (but before the rest of the body) + visitProgram: function (n) { + const lastImportIndex = n.node.body.findLastIndex( + (t) => t.type === "ImportDeclaration" + ); + const lastImport = n.get("body", lastImportIndex); + lastImport.insertAfter(...getPlatformProxyTsAstNodes); + + return this.traverse(n); + }, + // Pass the `platform` object from the declaration to the `qwikCity` plugin + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "qwikCity") { + return this.traverse(n); + } + + // The config object passed to `qwikCity` + const configArgument = n.node.arguments[0] as + | recast.types.namedTypes.ObjectExpression + | undefined; + + const platformPropery = b.objectProperty.from({ + key: b.identifier("platform"), + value: b.identifier("platform"), + shorthand: true, + }); + + if (!configArgument) { + n.node.arguments = [b.objectExpression([platformPropery])]; + + return false; + } + + if (configArgument.type !== "ObjectExpression") { + throw new Error("Failed to update `vite.config.ts`"); + } + + // Add the `platform` object to the object + configArgument.properties.push(platformPropery); + + return false; + }, + }); + + s.stop(`${brandColor("updated")} \`vite.config.ts\``); +} diff --git a/packages/autoconfig/src/frameworks/react-router.ts b/packages/autoconfig/src/frameworks/react-router.ts new file mode 100644 index 0000000..449e6e5 --- /dev/null +++ b/packages/autoconfig/src/frameworks/react-router.ts @@ -0,0 +1,524 @@ +import assert from "node:assert"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { parseFile, transformFile } from "@cloudflare/codemod"; +import * as recast from "recast"; +import semiver from "semiver"; +import dedent from "ts-dedent"; +import { Framework } from "./framework-class"; +import { transformViteConfig } from "./utils/vite-config"; +import { installCloudflareVitePlugin } from "./utils/vite-plugin"; +import type { AutoConfigContext } from "../context"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { Program } from "esprima"; + +const b = recast.types.builders; + +/** + * Resolves the path to the React Router config file (`react-router.config.ts` or `.js`) + * in the given project directory. + * + * @param projectPath - Absolute path to the project root. + * @returns The resolved config file path, or `null` if neither `.ts` nor `.js` variant exists. + */ +function getReactRouterConfigPath(projectPath: string): string | null { + const filePathTS = path.join(projectPath, "react-router.config.ts"); + const filePathJS = path.join(projectPath, "react-router.config.js"); + + if (existsSync(filePathTS)) { + return filePathTS; + } + + if (existsSync(filePathJS)) { + return filePathJS; + } + + return null; +} + +/** + * Checks whether the middleware pattern should be used for the given React Router project. + * + * For React Router >= 8.0.0, middleware is enabled by default + * (ref: https://github.com/remix-run/react-router/pull/15078), so this always returns `true`. + * + * For earlier versions, it parses the user's `react-router.config.ts` (or `.js`) to check + * whether `future.v8_middleware` is explicitly set to `true`. + * + * This determines which code pattern to generate: + * - With middleware: simplified fetch handler using `cloudflare:workers` env pattern + * - Without middleware: traditional `AppLoadContext` pattern with `env`/`ctx` params + * + * Parses the config file read-only (via `parseFile`) without writing anything to disk. + * Handles both TS `satisfies`/`as` expressions and plain object exports. + * + * @param projectPath - Absolute path to the project root containing the React Router config file. + * @param frameworkVersion - React Router semver version string. When provided and + * >= 8.0.0, returns `true` without parsing the config file (middleware is the default in v8). + * @returns `true` if middleware is enabled (either by default in v8+ or via `future.v8_middleware` + * in the config), `false` otherwise (including when the config file is missing or has no + * `future` block). + */ +export function hasV8MiddlewareFlag( + projectPath: string, + frameworkVersion?: string +): boolean { + // React Router >= 8.0.0 has middleware enabled by default + if (frameworkVersion && semiver(frameworkVersion, "8.0.0") >= 0) { + return true; + } + + const filePath = getReactRouterConfigPath(projectPath); + if (!filePath) { + return false; + } + + let ast: Program | null = null; + try { + ast = parseFile(filePath); + } catch {} + + if (!ast) { + return false; + } + + let found = false; + recast.visit(ast, { + visitExportDefaultDeclaration(n) { + let node: recast.types.namedTypes.ObjectExpression | null = null; + if ( + (n.node.declaration.type === "TSAsExpression" || + n.node.declaration.type === "TSSatisfiesExpression") && + n.node.declaration.expression.type === "ObjectExpression" + ) { + node = n.node.declaration.expression; + } else if (n.node.declaration.type === "ObjectExpression") { + node = n.node.declaration; + } + + if (node) { + const futureProp = node.properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "future" && + p.value.type === "ObjectExpression" + ); + if ( + futureProp?.type === "ObjectProperty" && + futureProp.value.type === "ObjectExpression" + ) { + found = futureProp.value.properties.some( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "v8_middleware" && + p.value.type === "BooleanLiteral" && + p.value.value === true + ); + } + } + return false; + }, + }); + + return found; +} + +/** + * Transforms the user's `react-router.config.ts` (or `.js`) to ensure the + * `future.unstable_viteEnvironmentApi` or `future.v8_viteEnvironmentApi` flag + * is set to `true`. If a `future` block already exists, the flag is added or + * updated in place; otherwise a new `future` block is created. + * + * Supports TS `as` and `satisfies` expressions wrapping the default export object. + * + * @param projectPath - Absolute path to the project root containing the React Router config file. + * @param viteEnvironmentKey - The config property name to set, either + * `"unstable_viteEnvironmentApi"` (React Router < 7.10.0) or `"v8_viteEnvironmentApi"`. + * @throws {Error} If no `react-router.config.ts` or `.js` file exists in the project. + * @throws {Error} If the default export cannot be parsed as an object expression. + */ +function transformReactRouterConfig( + projectPath: string, + viteEnvironmentKey: ReturnType +) { + const filePath = getReactRouterConfigPath(projectPath); + if (!filePath) { + throw new Error("Could not find React Router config file to modify"); + } + + transformFile(filePath, { + /** + * Visit an export default declaration of the form: + * + * export default { + * ... + * } + * + * and add or modify the `future` property to look like: + * + * future: { + * unstable_viteEnvironmentApi: true // v8_viteEnvironment depending on the React Router version + * } + * + * For some extra complexity, this also supports TS `as` and `satisfies` expressions + */ + visitExportDefaultDeclaration(n) { + let node: recast.types.namedTypes.ObjectExpression; + if ( + (n.node.declaration.type === "TSAsExpression" || + n.node.declaration.type === "TSSatisfiesExpression") && + n.node.declaration.expression.type === "ObjectExpression" + ) { + node = n.node.declaration.expression; + } else if (n.node.declaration.type === "ObjectExpression") { + node = n.node.declaration; + } else { + throw new Error( + `Could not parse React Router config file. Please add the following snippet manually:\n future: {\n ${viteEnvironmentKey}: true,\n }` + ); + } + + assert(node.type === "ObjectExpression"); + + // Is there an existing `future` key? If there is, we should modify it rather than creating a new one + const futureKey = node.properties.findIndex( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "future" && + p.value.type === "ObjectExpression" + ); + if (futureKey !== -1) { + const future = node.properties[futureKey]; + assert( + future.type === "ObjectProperty" && + future.value.type === "ObjectExpression" + ); + + // Does the `future` key already have a property called `unstable_viteEnvironmentApi`? + const viteEnvironment = future.value.properties.findIndex( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === viteEnvironmentKey && + p.value.type === "BooleanLiteral" + ); + + // If there's already a unstable_viteEnvironmentApi key, set the value to true + if (viteEnvironment !== -1) { + const prop = future.value.properties[viteEnvironment]; + assert( + prop.type === "ObjectProperty" && + prop.value.type === "BooleanLiteral" + ); + prop.value.value = true; + } else { + const prop = b.objectProperty( + b.identifier(viteEnvironmentKey), + b.booleanLiteral(true) + ); + future.value.properties.push(prop); + } + } else { + node.properties.push( + b.objectProperty( + b.identifier("future"), + b.objectExpression([ + b.objectProperty( + b.identifier(viteEnvironmentKey), + b.booleanLiteral(true) + ), + ]) + ) + ); + } + + return false; + }, + }); +} + +/** + * Returns the correct future flag property name for enabling the Vite Environment API + * based on the installed React Router version. + * + * @param reactRouterVersion - The installed React Router semver version string (e.g. `"7.16.0"`). + * When empty, defaults to the stable `"v8_viteEnvironmentApi"` name. + * @returns `"unstable_viteEnvironmentApi"` for versions before 7.10.0, + * or `"v8_viteEnvironmentApi"` for 7.10.0 and later. + */ +function viteEnvApiConfigPropertyName(reactRouterVersion: string) { + if (!reactRouterVersion) { + return "v8_viteEnvironmentApi"; + } + + // version less than 7.10.0 + if (semiver(reactRouterVersion, "7.10.0") === -1) { + return "unstable_viteEnvironmentApi"; + } else { + return "v8_viteEnvironmentApi"; + } +} + +/** + * Determines whether the installed React Router version still requires the explicit + * `future` flag in `react-router.config.ts` for enabling the vite environment API. + * + * React Router >= 8.0.0 enables these features by default + * (ref: https://github.com/remix-run/react-router/pull/15077), so no future flags + * are needed (they also can't be included as they would cause errors at build time). + * + * @param reactRouterVersion - The installed React Router semver version string (e.g. `"7.16.0"`). + * @returns `true` if the future flag needs to be set (versions < 8.0.0), `false` otherwise. + */ +function needsViteEnvAPIFutureFlag(reactRouterVersion: string): boolean { + if (!reactRouterVersion) { + return false; + } + + return semiver(reactRouterVersion, "8.0.0") < 0; +} + +/** + * Writes the `workers/app.ts` Worker entry point. When `v8_middleware` is enabled, + * generates a simplified fetch handler that delegates directly to the request handler. + * Otherwise generates the traditional pattern with `AppLoadContext` module augmentation + * and explicit `env`/`ctx` forwarding. + * + * @param useMiddlewarePattern - Whether `v8_middleware` is enabled in the user's config. + */ +function writeAppTs(useMiddlewarePattern: boolean) { + if (useMiddlewarePattern) { + writeFileSync( + "workers/app.ts", + dedent /* javascript */ ` + import { createRequestHandler } from "react-router"; + + const requestHandler = createRequestHandler( + () => import("virtual:react-router/server-build"), + import.meta.env.MODE, + ); + + export default { + async fetch(request) { + return requestHandler(request); + }, + } satisfies ExportedHandler; + ` + ); + return; + } + + writeFileSync( + "workers/app.ts", + dedent /* javascript */ ` + import { createRequestHandler } from "react-router"; + + declare module "react-router" { + export interface AppLoadContext { + cloudflare: { + env: Env; + ctx: ExecutionContext; + }; + } + } + + const requestHandler = createRequestHandler( + () => import("virtual:react-router/server-build"), + import.meta.env.MODE + ); + + export default { + async fetch(request, env, ctx) { + return requestHandler(request, { + cloudflare: { env, ctx }, + }); + }, + } satisfies ExportedHandler; + ` + ); +} + +/** + * Writes `app/entry.server.tsx` if it does not already exist. When `v8_middleware` + * is enabled, the generated file omits the `AppLoadContext` import and `_loadContext` + * parameter. Otherwise uses the traditional signature that includes both. + * + * If the file already exists on disk it is left untouched and a warning is logged. + * + * @param useMiddlewarePattern - Whether `v8_middleware` is enabled in the user's config. + * @param context - The autoconfig context providing logger and other dependencies. + */ +function writeEntryServerTsx( + useMiddlewarePattern: boolean, + context: AutoConfigContext +) { + if (existsSync("app/entry.server.tsx")) { + context.logger.warn( + "The file `app/entry.server.tsx` already exists on disk, and so we're not modifying it. This may lead to deployment failures if `app/entry.server.tsx` is not set up correctly." + ); + return; + } + + if (useMiddlewarePattern) { + writeFileSync( + `app/entry.server.tsx`, + dedent /* javascript */ ` + import type { EntryContext } from "react-router"; + import { ServerRouter } from "react-router"; + import { isbot } from "isbot"; + import { renderToReadableStream } from "react-dom/server"; + + export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + ) { + let shellRendered = false; + const userAgent = request.headers.get("user-agent"); + + const body = await renderToReadableStream( + , + { + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + shellRendered = true; + + // Ensure requests from bots and SPA Mode renders wait for all content to load before responding + // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation + if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); + } + ` + ); + return; + } + + writeFileSync( + `app/entry.server.tsx`, + dedent /* javascript */ ` + import type { AppLoadContext, EntryContext } from "react-router"; + import { ServerRouter } from "react-router"; + import { isbot } from "isbot"; + import { renderToReadableStream } from "react-dom/server"; + + export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext + ) { + let shellRendered = false; + const userAgent = request.headers.get("user-agent"); + + const body = await renderToReadableStream( + , + { + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + shellRendered = true; + + // Ensure requests from bots and SPA Mode renders wait for all content to load before responding + // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation + if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); + } + ` + ); +} + +export class ReactRouter extends Framework { + async configure({ + dryRun, + projectPath, + packageManager, + isWorkspaceRoot, + context, + }: ConfigurationOptions): Promise { + const useMiddlewarePattern = hasV8MiddlewareFlag( + projectPath, + this.frameworkVersion + ); + if (!dryRun) { + await installCloudflareVitePlugin({ + packageManager: packageManager.type, + projectPath, + isWorkspaceRoot, + }); + + mkdirSync("workers"); + + writeAppTs(useMiddlewarePattern); + + await installPackages(packageManager.type, ["isbot"], { + dev: true, + startText: "Installing the isbot package", + doneText: `${brandColor(`installed`)} ${dim("isbot")}`, + isWorkspaceRoot, + }); + + writeEntryServerTsx(useMiddlewarePattern, context); + + transformViteConfig(projectPath, { + viteEnvironmentName: "ssr", + incompatibleVitePlugins: ["netlifyReactRouter"], + }); + + if (needsViteEnvAPIFutureFlag(this.frameworkVersion)) { + const viteEnvironmentKey = viteEnvApiConfigPropertyName( + this.frameworkVersion + ); + transformReactRouterConfig(projectPath, viteEnvironmentKey); + } + } + + return { + wranglerConfig: { + main: "./workers/app.ts", + }, + }; + } + + configurationDescription = "Configuring project for React Router"; +} diff --git a/packages/autoconfig/src/frameworks/solid-start.ts b/packages/autoconfig/src/frameworks/solid-start.ts new file mode 100644 index 0000000..5e12292 --- /dev/null +++ b/packages/autoconfig/src/frameworks/solid-start.ts @@ -0,0 +1,127 @@ +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { blue } from "@cloudflare/cli-shared-helpers/colors"; +import { mergeObjectProperties, transformFile } from "@cloudflare/codemod"; +import { getTodaysCompatDate } from "@cloudflare/workers-utils"; +import * as recast from "recast"; +import semiver from "semiver"; +import { usesTypescript } from "../uses-typescript"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class SolidStart extends Framework { + async configure({ + projectPath, + dryRun, + }: ConfigurationOptions): Promise { + if (!dryRun) { + const solidStartVersion = this.frameworkVersion; + + if (semiver(solidStartVersion, "2.0.0-alpha") < 0) { + updateAppConfigFile(projectPath); + } else { + updateViteConfigFile(projectPath); + } + } + + return { + wranglerConfig: { + main: "./.output/server/index.mjs", + assets: { + binding: "ASSETS", + directory: "./.output/public", + }, + }, + }; + } +} + +/** + * This functions updates the `vite.config.(js|ts)` files used by SolidStart applications + * to use the `cloudflare-module` preset to target Cloudflare Workers. + * + * Note: SolidStart projects prior to version `2.0.0-alpha` used to have an `app.config.(js|ts)` file instead + * + * @param projectPath The path of the project + */ +function updateViteConfigFile(projectPath: string): void { + const filePath = `vite.config.${usesTypescript(projectPath) ? "ts" : "js"}`; + + transformFile(filePath, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "nitro") { + return this.traverse(n); + } + + const b = recast.types.builders; + const presetProp = b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare-module") + ); + + if (n.node.arguments.length === 0) { + n.node.arguments.push(b.objectExpression([presetProp])); + } else { + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + [presetProp] + ); + } + + return false; + }, + }); +} + +/** + * SolidStart apps used to have an `app.config.(js|ts)` before version `2.0.0-alpha` + * (afterwards this has been replaced by `vite.config.(js|ts)`). + * Reference: https://github.com/solidjs/templates/commit/c4cd73e08bdc + * + * This functions updates the `app.config.(js|ts)` to use the `cloudflare_module` preset + * to target Cloudflare Workers. + * + * @param projectPath The path of the project + */ +function updateAppConfigFile(projectPath: string): void { + const filePath = `app.config.${usesTypescript(projectPath) ? "ts" : "js"}`; + + const compatDate = getTodaysCompatDate(); + + updateStatus(`Updating configuration in ${blue(filePath)}`); + + transformFile(filePath, { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "defineConfig") { + return this.traverse(n); + } + + const b = recast.types.builders; + mergeObjectProperties( + n.node.arguments[0] as recast.types.namedTypes.ObjectExpression, + [ + b.objectProperty( + b.identifier("server"), + b.objectExpression([ + // preset: "cloudflare_module" + b.objectProperty( + b.identifier("preset"), + b.stringLiteral("cloudflare_module") + ), + b.objectProperty( + b.identifier("compatibilityDate"), + b.stringLiteral(compatDate) + ), + ]) + ), + ] + ); + + return false; + }, + }); +} diff --git a/packages/autoconfig/src/frameworks/static.ts b/packages/autoconfig/src/frameworks/static.ts new file mode 100644 index 0000000..0d1d796 --- /dev/null +++ b/packages/autoconfig/src/frameworks/static.ts @@ -0,0 +1,17 @@ +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class Static extends Framework { + configure({ outputDir }: ConfigurationOptions): ConfigurationResults { + return { + wranglerConfig: { + assets: { + directory: outputDir, + }, + }, + }; + } +} diff --git a/packages/autoconfig/src/frameworks/sveltekit.ts b/packages/autoconfig/src/frameworks/sveltekit.ts new file mode 100644 index 0000000..d37eeda --- /dev/null +++ b/packages/autoconfig/src/frameworks/sveltekit.ts @@ -0,0 +1,58 @@ +import { writeFileSync } from "node:fs"; +import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { runCommand } from "@cloudflare/cli-shared-helpers/command"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { Framework } from "./framework-class"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class SvelteKit extends Framework { + async configure({ + dryRun, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + const { dlx } = packageManager; + if (!dryRun) { + await runCommand( + [ + ...dlx, + "sv", + "add", + "--no-install", + "--no-git-check", + "sveltekit-adapter=adapter:cloudflare+cfTarget:workers", + ], + { + silent: true, + startText: "Installing adapter", + doneText: `${brandColor("installed")} ${dim( + `via \`${dlx.join( + " " + )} sv add sveltekit-adapter=adapter:cloudflare+cfTarget:workers\`` + )}`, + } + ); + writeFileSync("static/.assetsignore", "_worker.js\n_routes.json"); + + await installPackages(packageManager.type, [], { + startText: "Installing packages", + doneText: `${brandColor("installed")}`, + isWorkspaceRoot, + }); + } + return { + wranglerConfig: { + main: ".svelte-kit/cloudflare/_worker.js", + assets: { + binding: "ASSETS", + directory: ".svelte-kit/cloudflare", + }, + }, + }; + } + + configurationDescription = 'Configuring project for SvelteKit with "sv add"'; +} diff --git a/packages/autoconfig/src/frameworks/tanstack.ts b/packages/autoconfig/src/frameworks/tanstack.ts new file mode 100644 index 0000000..5b8f2ba --- /dev/null +++ b/packages/autoconfig/src/frameworks/tanstack.ts @@ -0,0 +1,34 @@ +import { Framework } from "./framework-class"; +import { transformViteConfig } from "./utils/vite-config"; +import { installCloudflareVitePlugin } from "./utils/vite-plugin"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class TanstackStart extends Framework { + async configure({ + dryRun, + projectPath, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + if (!dryRun) { + await installCloudflareVitePlugin({ + packageManager: packageManager.type, + isWorkspaceRoot, + projectPath, + }); + + transformViteConfig(projectPath, { viteEnvironmentName: "ssr" }); + } + + return { + wranglerConfig: { + main: "@tanstack/react-start/server-entry", + }, + }; + } + + configurationDescription = "Configuring project for Tanstack Start"; +} diff --git a/packages/autoconfig/src/frameworks/utils/packages.ts b/packages/autoconfig/src/frameworks/utils/packages.ts new file mode 100644 index 0000000..7d55577 --- /dev/null +++ b/packages/autoconfig/src/frameworks/utils/packages.ts @@ -0,0 +1,4 @@ +export { + isPackageInstalled, + getInstalledPackageVersion, +} from "@cloudflare/workers-utils"; diff --git a/packages/autoconfig/src/frameworks/utils/vite-config.ts b/packages/autoconfig/src/frameworks/utils/vite-config.ts new file mode 100644 index 0000000..42867b1 --- /dev/null +++ b/packages/autoconfig/src/frameworks/utils/vite-config.ts @@ -0,0 +1,323 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { transformFile } from "@cloudflare/codemod"; +import { UserError } from "@cloudflare/workers-utils"; +import * as recast from "recast"; +import dedent from "ts-dedent"; +import type { AutoConfigContext } from "../../context"; +import type { types } from "recast"; + +const b = recast.types.builders; +const t = recast.types.namedTypes; + +/** + * Extracts the ObjectExpression from the first argument of defineConfig(). + * + * Handles: + * - defineConfig({ ... }) + * - defineConfig(() => ({ ... })) + * - defineConfig(({ isSsrBuild }) => ({ ... })) + * - defineConfig(() => { return { ... }; }) + * - defineConfig(function() { return { ... }; }) + */ +function extractConfigObject( + node: recast.types.ASTNode +): types.namedTypes.ObjectExpression | null { + // Direct object: defineConfig({ ... }) + if (t.ObjectExpression.check(node)) { + return node; + } + + // Arrow function: defineConfig(() => ({ ... })) or defineConfig(() => { return { ... }; }) + if (t.ArrowFunctionExpression.check(node)) { + if (t.ObjectExpression.check(node.body)) { + return node.body; + } + if (t.BlockStatement.check(node.body)) { + return extractFromBlockStatement(node.body); + } + } + + // Function expression: defineConfig(function() { return { ... }; }) + if (t.FunctionExpression.check(node)) { + return extractFromBlockStatement(node.body); + } + + return null; +} + +function extractFromBlockStatement( + block: types.namedTypes.BlockStatement +): types.namedTypes.ObjectExpression | null { + const returnStmt = block.body.find((s) => t.ReturnStatement.check(s)); + if ( + returnStmt && + t.ReturnStatement.check(returnStmt) && + returnStmt.argument && + t.ObjectExpression.check(returnStmt.argument) + ) { + return returnStmt.argument; + } + return null; +} + +export function checkIfViteConfigUsesCloudflarePlugin( + projectPath: string, + context?: AutoConfigContext +): boolean { + const filePath = getViteConfigPath(projectPath); + + let importsCloudflarePlugin = false; + let usesCloudflarePlugin = false; + + transformFile(filePath, { + visitProgram(n) { + if ( + n.node.body.some( + (s) => + s.type === "ImportDeclaration" && + s.source.value === "@cloudflare/vite-plugin" + ) + ) { + importsCloudflarePlugin = true; + return this.traverse(n); + } + + this.traverse(n); + }, + visitCallExpression: function (n) { + const callee = n.node.callee as types.namedTypes.Identifier; + if (callee.name !== "defineConfig") { + return this.traverse(n); + } + + const configObject = extractConfigObject(n.node.arguments[0]); + if (!configObject) { + context?.logger.debug( + `Vite config uses an unsupported expression type. Skipping Cloudflare plugin check.` + ); + return this.traverse(n); + } + + const pluginsProp = configObject.properties.find((prop) => + isPluginsProp(prop) + ); + if (!pluginsProp || !t.ArrayExpression.check(pluginsProp.value)) { + context?.logger.debug( + `Vite config does not have a valid plugins array. Skipping Cloudflare plugin check.` + ); + return this.traverse(n); + } + + if ( + pluginsProp.value.elements.some( + (el) => + el?.type === "CallExpression" && + el.callee.type === "Identifier" && + el.callee.name === "cloudflare" + ) + ) { + usesCloudflarePlugin = true; + return this.traverse(n); + } + + this.traverse(n); + }, + }); + + return importsCloudflarePlugin && usesCloudflarePlugin; +} + +/** + * Returns whether a Vite config file (`vite.config.ts` or `vite.config.js`) + * exists in the given project directory. + */ +export function hasViteConfig(projectPath: string): boolean { + return ( + existsSync(path.join(projectPath, "vite.config.ts")) || + existsSync(path.join(projectPath, "vite.config.js")) + ); +} + +function getViteConfigPath(projectPath: string): string { + const filePathTS = path.join(projectPath, `vite.config.ts`); + const filePathJS = path.join(projectPath, `vite.config.js`); + + let filePath: string; + + if (existsSync(filePathTS)) { + filePath = filePathTS; + } else if (existsSync(filePathJS)) { + filePath = filePathJS; + } else { + throw new Error("Could not find Vite config file to modify"); + } + + return filePath; +} + +/** + * Name of vite plugins that we know are incompatible with the Cloudflare one + */ +const knownIncompatiblePlugins = ["nitro", "nitroV2Plugin", "netlify"]; + +export function transformViteConfig( + projectPath: string, + options: { + viteEnvironmentName?: string; + incompatibleVitePlugins?: string[]; + } = {} +) { + const filePath = getViteConfigPath(projectPath); + + transformFile(filePath, { + visitProgram(n) { + // Add an import of the @cloudflare/vite-plugin + // ``` + // import {cloudflare} from "@cloudflare/vite-plugin"; + // ``` + const lastImportIndex = n.node.body.findLastIndex( + (statement) => statement.type === "ImportDeclaration" + ); + const lastImport = n.get("body", lastImportIndex); + const importAst = b.importDeclaration( + [b.importSpecifier(b.identifier("cloudflare"))], + b.stringLiteral("@cloudflare/vite-plugin") + ); + + // Only import if not already imported + if ( + !n.node.body.some( + (s) => + s.type === "ImportDeclaration" && + s.source.value === "@cloudflare/vite-plugin" + ) + ) { + lastImport.insertAfter(importAst); + } + + return this.traverse(n); + }, + visitCallExpression: function (n) { + // Add the imported plugin to the config + // ``` + // defineConfig({ + // plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } })], + // }); + // ``` + // Also handles function-based configs like: + // ``` + // defineConfig(({ isSsrBuild }) => ({ + // plugins: [...] + // })); + // ``` + const callee = n.node.callee as types.namedTypes.Identifier; + if (callee.name !== "defineConfig") { + return this.traverse(n); + } + + const configObject = extractConfigObject(n.node.arguments[0]); + if (!configObject) { + const argType = n.node.arguments[0]?.type ?? "unknown"; + throw new UserError( + dedent` + Cannot modify Vite config: could not extract a config object (found ${argType}). + + The Cloudflare plugin can only be automatically added to Vite configs that use: + - A simple object: defineConfig({ plugins: [...] }) + - An arrow function returning an object: defineConfig(() => ({ plugins: [...] })) + + If your config uses a more complex pattern, please manually add the plugin: + + import { cloudflare } from "@cloudflare/vite-plugin"; + + export default defineConfig({ + plugins: [cloudflare()] + }); + `, + { + telemetryMessage: "autoconfig vite config object unsupported", + } + ); + } + + const pluginsProp = configObject.properties.find((prop) => + isPluginsProp(prop) + ); + if (!pluginsProp || !t.ArrayExpression.check(pluginsProp.value)) { + throw new UserError( + dedent` + Cannot modify Vite config: could not find a valid plugins array. + + Please ensure your Vite config has a plugins array: + + export default defineConfig({ + plugins: [] + }); + `, + { + telemetryMessage: "autoconfig vite plugins array missing", + } + ); + } + + // Only add the Cloudflare plugin if it's not already present + if ( + !pluginsProp.value.elements.some( + (el) => + el?.type === "CallExpression" && + el.callee.type === "Identifier" && + el.callee.name === "cloudflare" + ) + ) { + pluginsProp.value.elements.push( + b.callExpression(b.identifier("cloudflare"), [ + ...(options.viteEnvironmentName + ? [ + b.objectExpression([ + b.objectProperty( + b.identifier("viteEnvironment"), + b.objectExpression([ + b.objectProperty( + b.identifier("name"), + b.stringLiteral(options.viteEnvironmentName) + ), + ]) + ), + ]), + ] + : []), + ]) + ); + } + + const incompatibleVitePlugins = [ + ...knownIncompatiblePlugins, + ...(options.incompatibleVitePlugins ?? []), + ]; + + // Remove incompatible plugins + pluginsProp.value.elements = pluginsProp.value.elements.filter((el) => { + if ( + el?.type === "CallExpression" && + el.callee.type === "Identifier" && + incompatibleVitePlugins.includes(el.callee.name) + ) { + return false; + } + return true; + }); + return false; + }, + }); +} + +function isPluginsProp( + prop: unknown +): prop is types.namedTypes.ObjectProperty | types.namedTypes.Property { + return ( + (t.Property.check(prop) || t.ObjectProperty.check(prop)) && + t.Identifier.check(prop.key) && + prop.key.name === "plugins" + ); +} diff --git a/packages/autoconfig/src/frameworks/utils/vite-plugin.ts b/packages/autoconfig/src/frameworks/utils/vite-plugin.ts new file mode 100644 index 0000000..c6eabd0 --- /dev/null +++ b/packages/autoconfig/src/frameworks/utils/vite-plugin.ts @@ -0,0 +1,51 @@ +import { brandColor, dim } from "@cloudflare/cli-shared-helpers/colors"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import semiver from "semiver"; +import { getInstalledPackageVersion } from "./packages"; +import type { PackageManager } from "@cloudflare/workers-utils"; + +/** + * Installs the `@cloudflare/vite-plugin` package as a dev dependency + * + * If the project has Vite >= 6.0.0 but < 6.1.0 installed, it will first + * be updated to `^6.1.0` to ensure compatibility with the plugin. + * + * @param packageManager the type of package manager to use for installation + * @param projectPath the path of the project (used to check the installed Vite version) + * @param isWorkspaceRoot whether the current project is a workspace root + */ +export async function installCloudflareVitePlugin({ + packageManager, + projectPath, + isWorkspaceRoot, +}: { + packageManager: PackageManager["type"]; + projectPath: string; + isWorkspaceRoot: boolean; +}): Promise { + const viteVersion = getInstalledPackageVersion("vite", projectPath); + + if ( + viteVersion && + semiver(viteVersion, "6.0.0") >= 0 && + semiver(viteVersion, "6.1.0") < 0 + ) { + // If the vite version is between 6.0.0 and 6.1.0 lets bump it to + // the latest version of 6.x, in this way it will be compatible + // with the vite plugin (likely without causing any inconvenience) + await installPackages(packageManager, ["vite@^6.1.0"], { + dev: true, + startText: + "Updating the version of vite to be compatible with the Cloudflare Vite Plugin", + doneText: `${brandColor(`updated`)} ${dim("Vite")}`, + isWorkspaceRoot, + }); + } + + await installPackages(packageManager, ["@cloudflare/vite-plugin"], { + dev: true, + startText: "Installing the Cloudflare Vite plugin", + doneText: `${brandColor(`installed`)} ${dim("@cloudflare/vite-plugin")}`, + isWorkspaceRoot, + }); +} diff --git a/packages/autoconfig/src/frameworks/vike.ts b/packages/autoconfig/src/frameworks/vike.ts new file mode 100644 index 0000000..eb9ae0a --- /dev/null +++ b/packages/autoconfig/src/frameworks/vike.ts @@ -0,0 +1,293 @@ +import assert from "node:assert"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { brandColor } from "@cloudflare/cli-shared-helpers/colors"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { transformFile } from "@cloudflare/codemod"; +import * as recast from "recast"; +import { Framework } from "./framework-class"; +import { isPackageInstalled } from "./utils/packages"; +import { installCloudflareVitePlugin } from "./utils/vite-plugin"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { types } from "recast"; + +const b = recast.types.builders; +const t = recast.types.namedTypes; + +export class Vike extends Framework { + async configure({ + projectPath, + dryRun, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + const vikeServerIsInstalled = isPackageInstalled( + "vike-server", + projectPath + ); + if (vikeServerIsInstalled) { + // Note: if vike-server is used we just error, this is the simplest solution, alternatively we could + // migrate the project as described in https://vike.dev/migration/vike-photon, but that is probably + // not currently worth the effort (but something we might consider if/when needed) + throw new Error( + 'Aborting since the project is using the deprecated "vike-server" package, please remove the package from your project and try again.' + ); + } + + if (!dryRun) { + // note: the following installation steps follow the guide in: https://vike.dev/cloudflare#get-started + + await installPackages( + packageManager.type, + ["vike-photon", "@photonjs/cloudflare"], + { + startText: "Installing vike-photon and @photonjs/cloudflare", + doneText: `${brandColor(`installed`)} photon packages`, + isWorkspaceRoot, + } + ); + + await installCloudflareVitePlugin({ + packageManager: packageManager.type, + isWorkspaceRoot, + projectPath, + }); + + addVikePhotonToConfigFile(projectPath); + } + + return { + wranglerConfig: { + main: "virtual:photon:cloudflare:server-entry", + }, + packageJsonScriptsOverrides: { + preview: "vike build && vike preview", + deploy: "vike build && wrangler deploy", + }, + }; + } +} + +/** + * Modifies a vike config file present at `pages/+config.(js|ts)` to import and use `vikePhoton` + * + * @param projectPath The project's path + */ +function addVikePhotonToConfigFile(projectPath: string) { + const filePath = getConfigPath(projectPath); + let programNode: types.namedTypes.Program | undefined; + + transformFile(filePath, { + visitProgram(n) { + programNode = n.node; + addVikePhotonImportToConfigFile(n); + return this.traverse(n); + }, + visitExportDefaultDeclaration(n) { + addVikePhotonToVikeConfigExportObject(n, programNode); + return this.traverse(n); + }, + }); +} + +/** Name of the vike config property that needs to include vikePhoton */ +const vikeConfigExtendsPropName = "extends"; + +/** + * Given a recast visitor node path for the vike config export declaration it adds vikePhoton to the config extends property + * + * @param n node path for the vike config file's default export + */ +function addVikePhotonToVikeConfigExportObject( + n: Parameters>[0], + programNode?: types.namedTypes.Program +) { + const configObject = getVikeConfigObjectExpression(n, programNode); + + let configTargetProp = configObject.properties.find((prop) => + isExtendsProp(prop) + ); + if (!configTargetProp) { + configTargetProp = b.objectProperty( + b.identifier(vikeConfigExtendsPropName), + b.arrayExpression([]) + ); + configObject.properties.push(configTargetProp); + } + + assert(configTargetProp && t.ArrayExpression.check(configTargetProp.value)); + + // Determine the local import name for "vike-photon/config" (defaults to "vikePhoton"). + // This handles cases where the user already imported it under a different name. + const vikePhotonLocalName = + getVikePhotonImportLocalName(programNode) ?? "vikePhoton"; + + // Only add vikePhoton if it's not already present + if ( + !configTargetProp.value.elements.some( + (el) => el?.type === "Identifier" && el.name === vikePhotonLocalName + ) + ) { + configTargetProp.value.elements.push(b.identifier(vikePhotonLocalName)); + } +} + +/** + * Finds the local name used for the default import from "vike-photon/config". + * e.g. `import vikePhoton from "vike-photon/config"` returns "vikePhoton", + * `import photon from "vike-photon/config"` returns "photon". + */ +function getVikePhotonImportLocalName( + programNode?: types.namedTypes.Program +): string | undefined { + if (!programNode) { + return undefined; + } + for (const stmt of programNode.body) { + if ( + t.ImportDeclaration.check(stmt) && + stmt.source.value === "vike-photon/config" + ) { + const defaultSpec = stmt.specifiers?.find((s) => + t.ImportDefaultSpecifier.check(s) + ); + if (defaultSpec && t.Identifier.check(defaultSpec.local)) { + return defaultSpec.local.name; + } + } + } + return undefined; +} + +/** + * Given a recast visitor node path for the vike config export declaration it returns the object expression associated to it + * + * @param n node path for the vike config file's default export + */ +function getVikeConfigObjectExpression( + n: Parameters>[0], + programNode?: types.namedTypes.Program +): types.namedTypes.ObjectExpression { + if (n.node.declaration.type === "ObjectExpression") { + // The export is a simple object expression + return n.node.declaration; + } + + if ( + (n.node.declaration.type === "TSAsExpression" || + n.node.declaration.type === "TSSatisfiesExpression") && + n.node.declaration.expression.type === "ObjectExpression" + ) { + // The export is an `as Config` or `satisfies Config` expression, so we go a level + // deeper to get the object expression + return n.node.declaration.expression; + } + + if (n.node.declaration.type === "Identifier" && programNode) { + // The export is a variable reference, e.g., `const config: Config = { ... }; export default config;` + const objectExpression = resolveIdentifierToObjectExpression( + n.node.declaration.name, + programNode + ); + if (objectExpression) { + return objectExpression; + } + } + + throw new Error("Could not determine Vike default object export"); +} + +/** + * Resolves a variable name to its ObjectExpression initializer by searching the program body. + * Handles both plain object initializers and those wrapped in TSAsExpression/TSSatisfiesExpression. + */ +function resolveIdentifierToObjectExpression( + varName: string, + programNode: types.namedTypes.Program +): types.namedTypes.ObjectExpression | undefined { + for (const stmt of programNode.body) { + if (t.VariableDeclaration.check(stmt)) { + for (const declarator of stmt.declarations) { + if ( + t.VariableDeclarator.check(declarator) && + t.Identifier.check(declarator.id) && + declarator.id.name === varName && + declarator.init + ) { + if (t.ObjectExpression.check(declarator.init)) { + return declarator.init; + } + if ( + (t.TSAsExpression.check(declarator.init) || + t.TSSatisfiesExpression.check(declarator.init)) && + t.ObjectExpression.check(declarator.init.expression) + ) { + return declarator.init.expression; + } + } + } + } + } + return undefined; +} + +/** + * Given a recast visitor node path for the vike config file it adds the following vikePhoton import to the file (if not already present): + * ``` + * import vikePhoton from "vike-photon/config"; + * ``` + * @param n node path for the vike config file + */ +function addVikePhotonImportToConfigFile( + n: Parameters>[0] +): void { + const lastImportIndex = n.node.body.findLastIndex( + (statement) => statement.type === "ImportDeclaration" + ); + const lastImport = n.get("body", lastImportIndex); + const importAst = b.importDeclaration( + [b.importDefaultSpecifier(b.identifier("vikePhoton"))], + b.stringLiteral("vike-photon/config") + ); + + // Only import if not already imported + if ( + !n.node.body.some( + (s) => + s.type === "ImportDeclaration" && + s.source.value === "vike-photon/config" + ) + ) { + lastImport.insertAfter(importAst); + } +} + +function getConfigPath(projectPath: string): string { + const filePathTS = path.join(projectPath, "pages", "+config.ts"); + const filePathJS = path.join(projectPath, "pages", "+config.js"); + + let filePath: string; + + if (existsSync(filePathTS)) { + filePath = filePathTS; + } else if (existsSync(filePathJS)) { + filePath = filePathJS; + } else { + throw new Error("Could not find config file to modify"); + } + + return filePath; +} + +function isExtendsProp( + prop: unknown +): prop is types.namedTypes.ObjectProperty | types.namedTypes.Property { + return ( + (t.Property.check(prop) || t.ObjectProperty.check(prop)) && + t.Identifier.check(prop.key) && + prop.key.name === vikeConfigExtendsPropName + ); +} diff --git a/packages/autoconfig/src/frameworks/vite.ts b/packages/autoconfig/src/frameworks/vite.ts new file mode 100644 index 0000000..a8d7a83 --- /dev/null +++ b/packages/autoconfig/src/frameworks/vite.ts @@ -0,0 +1,73 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { usesTypescript } from "../uses-typescript"; +import { Framework } from "./framework-class"; +import { + checkIfViteConfigUsesCloudflarePlugin, + hasViteConfig, + transformViteConfig, +} from "./utils/vite-config"; +import { installCloudflareVitePlugin } from "./utils/vite-plugin"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; + +export class Vite extends Framework { + isConfigured(projectPath: string): boolean { + if (!hasViteConfig(projectPath)) { + return false; + } + return checkIfViteConfigUsesCloudflarePlugin(projectPath); + } + + async configure({ + dryRun, + projectPath, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + if (!dryRun) { + await installCloudflareVitePlugin({ + packageManager: packageManager.type, + isWorkspaceRoot, + projectPath, + }); + + if (hasViteConfig(projectPath)) { + transformViteConfig(projectPath); + } else { + createViteConfig(projectPath); + } + } + + return { + wranglerConfig: { + assets: { + not_found_handling: "single-page-application", + }, + }, + }; + } +} + +/** + * Creates a minimal `vite.config` file with the Cloudflare plugin already + * configured. Used when a Vite project has no config file (e.g. projects + * scaffolded with `npm create vite@latest --template vanilla`). + * + * The file extension (`.ts` or `.js`) is chosen based on whether the project + * has a `tsconfig.json`. + */ +function createViteConfig(projectPath: string): void { + const ext = usesTypescript(projectPath) ? "ts" : "js"; + const filePath = join(projectPath, `vite.config.${ext}`); + const content = `import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ +\tplugins: [cloudflare()], +}); +`; + writeFileSync(filePath, content); +} diff --git a/packages/autoconfig/src/frameworks/waku.ts b/packages/autoconfig/src/frameworks/waku.ts new file mode 100644 index 0000000..9eac04a --- /dev/null +++ b/packages/autoconfig/src/frameworks/waku.ts @@ -0,0 +1,210 @@ +import assert from "node:assert"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { updateStatus } from "@cloudflare/cli-shared-helpers"; +import { blue, brandColor } from "@cloudflare/cli-shared-helpers/colors"; +import { installPackages } from "@cloudflare/cli-shared-helpers/packages"; +import { transformFile } from "@cloudflare/codemod"; +import * as recast from "recast"; +import dedent from "ts-dedent"; +import { Framework } from "./framework-class"; +import { installCloudflareVitePlugin } from "./utils/vite-plugin"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "./framework-class"; +import type { types } from "recast"; + +const b = recast.types.builders; +const t = recast.types.namedTypes; + +export class Waku extends Framework { + async configure({ + dryRun, + projectPath, + packageManager, + isWorkspaceRoot, + }: ConfigurationOptions): Promise { + if (!dryRun) { + await installPackages(packageManager.type, ["hono"], { + dev: true, + startText: "Installing hono dependency", + doneText: `${brandColor("installed")}`, + isWorkspaceRoot, + }); + + await installCloudflareVitePlugin({ + packageManager: packageManager.type, + projectPath, + isWorkspaceRoot, + }); + + await createWakuServerFile(projectPath); + await updateWakuConfig(projectPath); + } + + return { + wranglerConfig: { + main: "./src/waku.server", + assets: { + binding: "ASSETS", + directory: "./dist/public", + html_handling: "drop-trailing-slash", + }, + }, + }; + } +} + +/** + * Created a waku.server.tsx file that uses the Cloudflare adapter + * + * @param projectPath Path to the project + */ +async function createWakuServerFile(projectPath: string) { + await writeFile( + `${projectPath}/src/waku.server.tsx`, + dedent` + import { fsRouter } from 'waku'; + import adapter from 'waku/adapters/cloudflare'; + + export default adapter( + fsRouter(import.meta.glob('./**/*.{tsx,ts}', { base: './pages' })), + { + handlers: { + // Define additional Cloudflare Workers handlers here + // https://developers.cloudflare.com/workers/runtime-apis/handlers/ + // async queue( + // batch: MessageBatch, + // _env: Env, + // _ctx: ExecutionContext, + // ): Promise { + // for (const message of batch.messages) { + // console.log('Received', message); + // } + // }, + }, + }, + ); + ` + ); +} + +/** + * Updated the waku.config.ts file to import and use the Cloudflare Vite plugin + * + * @param projectPath Path to the project + */ +async function updateWakuConfig(projectPath: string) { + const wakuConfigPath = join(projectPath, "waku.config.ts"); + + if (!existsSync(wakuConfigPath)) { + throw new Error("Could not find Waku config file to modify"); + } + + updateStatus(`Updating Waku configuration in ${blue(wakuConfigPath)}`); + + transformFile(wakuConfigPath, { + visitProgram(n) { + // Add an import of the @cloudflare/vite-plugin + // ``` + // import { cloudflare } from '@cloudflare/vite-plugin'; + // ``` + const lastImportIndex = n.node.body.findLastIndex( + (statement) => statement.type === "ImportDeclaration" + ); + const lastImport = n.get("body", lastImportIndex); + + // Only import if not already imported + if ( + !n.node.body.some( + (s) => + s.type === "ImportDeclaration" && + s.source.value === "@cloudflare/vite-plugin" + ) + ) { + const importAst = b.importDeclaration( + [b.importSpecifier(b.identifier("cloudflare"))], + b.stringLiteral("@cloudflare/vite-plugin") + ); + lastImport.insertAfter(importAst); + } + + return this.traverse(n); + }, + visitCallExpression: function (n) { + const callee = n.node.callee as types.namedTypes.Identifier; + if (callee.name !== "defineConfig") { + return this.traverse(n); + } + + const config = n.node.arguments[0]; + assert(t.ObjectExpression.check(config)); + const viteConfig = config.properties.find((prop) => + isViteProp(prop) + )?.value; + assert(t.ObjectExpression.check(viteConfig)); + const pluginsProp = viteConfig.properties.find((prop) => + isPluginsProp(prop) + ); + assert(pluginsProp && t.ArrayExpression.check(pluginsProp.value)); + + // Only add the Cloudflare loader plugin if it's not already present + if ( + !pluginsProp.value.elements.some( + (el) => + el?.type === "CallExpression" && + el.callee.type === "Identifier" && + el.callee.name === "cloudflare" + ) + ) { + pluginsProp.value.elements.push( + b.callExpression(b.identifier("cloudflare"), [ + b.objectExpression([ + b.objectProperty( + b.identifier("viteEnvironment"), + b.objectExpression([ + b.objectProperty( + b.identifier("name"), + b.stringLiteral("rsc") + ), + b.objectProperty( + b.identifier("childEnvironments"), + b.arrayExpression([b.stringLiteral("ssr")]) + ), + ]) + ), + b.objectProperty( + b.identifier("inspectorPort"), + b.booleanLiteral(false) + ), + ]), + ]) + ); + } + + this.traverse(n); + }, + }); +} + +function isViteProp( + prop: unknown +): prop is types.namedTypes.ObjectProperty | types.namedTypes.Property { + return ( + (t.Property.check(prop) || t.ObjectProperty.check(prop)) && + t.Identifier.check(prop.key) && + prop.key.name === "vite" + ); +} + +function isPluginsProp( + prop: unknown +): prop is types.namedTypes.ObjectProperty | types.namedTypes.Property { + return ( + (t.Property.check(prop) || t.ObjectProperty.check(prop)) && + t.Identifier.check(prop.key) && + prop.key.name === "plugins" + ); +} diff --git a/packages/autoconfig/src/index.ts b/packages/autoconfig/src/index.ts new file mode 100644 index 0000000..a2f9df1 --- /dev/null +++ b/packages/autoconfig/src/index.ts @@ -0,0 +1,39 @@ +export type { + AutoConfigContext, + AutoConfigLogger, + AutoConfigDialogs, +} from "./context"; + +export { getDetailsForAutoConfig } from "./details"; +export { runAutoConfig, buildOperationsSummary } from "./run"; + +export { Framework } from "./frameworks/framework-class"; +export type { + ConfigurationOptions, + ConfigurationResults, + PackageJsonScriptsOverrides, +} from "./frameworks/framework-class"; + +export { isFrameworkSupported } from "./frameworks"; + +export type { + FrameworkInfo, + AutoConfigFrameworkPackageInfo, +} from "./frameworks"; + +export { displayAutoConfigDetails, confirmAutoConfigDetails } from "./details"; + +export type { + AutoConfigDetails, + AutoConfigDetailsForConfiguredProject, + AutoConfigDetailsForNonConfiguredProject, + AutoConfigOptions, + AutoConfigSummary, +} from "./types"; + +export { + AutoConfigDetectionError, + AutoConfigFrameworkConfigurationError, +} from "./errors"; + +export { getInstalledPackageVersion } from "@cloudflare/workers-utils"; diff --git a/packages/autoconfig/src/run.ts b/packages/autoconfig/src/run.ts new file mode 100644 index 0000000..58b045b --- /dev/null +++ b/packages/autoconfig/src/run.ts @@ -0,0 +1,448 @@ +import assert from "node:assert"; +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + maybeAppendWranglerToGitIgnoreLikeFile, + maybeAppendWranglerToGitIgnore, +} from "@cloudflare/cli-shared-helpers/gitignore"; +import { installWrangler } from "@cloudflare/cli-shared-helpers/packages"; +import { + FatalError, + getTodaysCompatDate, + parseJSONC, +} from "@cloudflare/workers-utils"; +import { + assertNonConfigured, + confirmAutoConfigDetails, + displayAutoConfigDetails, +} from "./details"; +import { + isFrameworkSupported, + isKnownFramework, + type PackageJsonScriptsOverrides, +} from "./frameworks"; +import { getFrameworkPackageInfo } from "./frameworks/all-frameworks"; +import { Static } from "./frameworks/static"; +import { usesTypescript } from "./uses-typescript"; +import type { AutoConfigContext } from "./context"; +import type { + AutoConfigDetails, + AutoConfigDetailsForNonConfiguredProject, + AutoConfigOptions, + AutoConfigSummary, +} from "./types"; +import type { PackageJSON, RawConfig } from "@cloudflare/workers-utils"; + +/** + * Runs the full autoconfig flow: displays detected settings, confirms with the user, + * validates the framework version, runs framework configuration, writes wrangler config, + * updates package.json scripts, and optionally runs the build command. + * + * @param autoConfigDetails - The detected project details from `getDetailsForAutoConfig()`. + * @param autoConfigOptions - Options controlling dry-run, confirmations, build, and context. + * @returns A summary of all operations performed. + */ +export async function runAutoConfig( + autoConfigDetails: AutoConfigDetails, + autoConfigOptions: AutoConfigOptions +): Promise { + const { context } = autoConfigOptions; + const { logger } = context; + const dryRun = autoConfigOptions.dryRun === true; + const runBuild = !dryRun && (autoConfigOptions.runBuild ?? true); + const skipConfirmations = + dryRun || autoConfigOptions.skipConfirmations === true; + const enableWranglerInstallation = + autoConfigOptions.enableWranglerInstallation ?? true; + + assertNonConfigured(autoConfigDetails); + + displayAutoConfigDetails(autoConfigDetails, context); + + const updatedAutoConfigDetails = skipConfirmations + ? autoConfigDetails + : await confirmAutoConfigDetails(autoConfigDetails, context); + + if (autoConfigDetails !== updatedAutoConfigDetails) { + displayAutoConfigDetails(updatedAutoConfigDetails, context, { + heading: "Updated Project Settings:", + }); + } + + autoConfigDetails = updatedAutoConfigDetails; + assertNonConfigured(autoConfigDetails); + + if (isKnownFramework(autoConfigDetails.framework.id)) { + const frameworkIsSupported = isFrameworkSupported( + autoConfigDetails.framework.id + ); + if (!frameworkIsSupported) { + throw new FatalError( + autoConfigDetails.framework.id === "cloudflare-pages" + ? `The target project seems to be using Cloudflare Pages. Automatically migrating from a Pages project to Workers is not yet supported.` + : `The detected framework ("${autoConfigDetails.framework.name}") cannot be automatically configured.`, + { telemetryMessage: "autoconfig run framework unsupported" } + ); + } + } + + assert( + autoConfigDetails.outputDir, + "The Output Directory is unexpectedly missing" + ); + + const compatibilityDate = getTodaysCompatDate(); + + const wranglerConfig: RawConfig = { + $schema: "node_modules/wrangler/config-schema.json", + name: autoConfigDetails.workerName, + compatibility_date: compatibilityDate, + observability: { + enabled: true, + }, + } satisfies RawConfig; + + const { packageManager } = autoConfigDetails; + + const isWorkspaceRoot = autoConfigDetails.isWorkspaceRoot ?? false; + + const frameworkPackageInfo = getFrameworkPackageInfo( + autoConfigDetails.framework.id + ); + if (frameworkPackageInfo) { + autoConfigDetails.framework.validateFrameworkVersion( + autoConfigDetails.projectPath, + frameworkPackageInfo, + context + ); + } + + const dryRunConfigurationResults = + await autoConfigDetails.framework.configure({ + outputDir: autoConfigDetails.outputDir, + projectPath: autoConfigDetails.projectPath, + workerName: autoConfigDetails.workerName, + isWorkspaceRoot, + dryRun: true, + packageManager, + context, + }); + + const { npx } = packageManager; + + const autoConfigSummary = await buildOperationsSummary( + { ...autoConfigDetails, outputDir: autoConfigDetails.outputDir }, + dryRunConfigurationResults.wranglerConfig === null + ? null + : ensureNodejsCompatIsInConfig({ + ...wranglerConfig, + ...dryRunConfigurationResults.wranglerConfig, + }), + { + build: + dryRunConfigurationResults.buildCommandOverride ?? + autoConfigDetails.buildCommand, + deploy: + dryRunConfigurationResults.deployCommandOverride ?? + `${npx} wrangler deploy`, + version: + dryRunConfigurationResults?.versionCommandOverride ?? + `${npx} wrangler versions upload`, + }, + context, + dryRunConfigurationResults.packageJsonScriptsOverrides + ); + + if ( + !( + skipConfirmations || + (await context.dialogs.confirm("Proceed with setup?")) + ) + ) { + throw new FatalError("Setup cancelled", { + telemetryMessage: "autoconfig run setup cancelled", + }); + } + + if (dryRun) { + logger.log( + `✋ ${"Autoconfig process run in dry-run mode, existing now."}` + ); + logger.log(""); + + return autoConfigSummary; + } + + logger.debug( + `Running autoconfig with:\n${JSON.stringify(autoConfigDetails, null, 2)}...` + ); + + if (autoConfigSummary.wranglerInstall && enableWranglerInstallation) { + await installWrangler(packageManager.type, isWorkspaceRoot); + } + + const configurationResults = await autoConfigDetails.framework.configure({ + outputDir: autoConfigDetails.outputDir, + projectPath: autoConfigDetails.projectPath, + workerName: autoConfigDetails.workerName, + isWorkspaceRoot, + dryRun: false, + packageManager, + context, + }); + + if (autoConfigDetails.packageJson) { + const packageJsonPath = resolve( + autoConfigDetails.projectPath, + "package.json" + ); + const existingPackageJson = JSON.parse( + await readFile(packageJsonPath, "utf8") + ) as PackageJSON; + + await writeFile( + packageJsonPath, + JSON.stringify( + { + ...existingPackageJson, + scripts: { + ...existingPackageJson.scripts, + ...autoConfigSummary.scripts, + }, + } satisfies PackageJSON, + null, + 2 + ) + "\n" + ); + } + + if (configurationResults.wranglerConfig !== null) { + await saveWranglerJsonc( + autoConfigDetails.projectPath, + ensureNodejsCompatIsInConfig({ + ...wranglerConfig, + ...configurationResults.wranglerConfig, + }) + ); + } + + maybeAppendWranglerToGitIgnore(autoConfigDetails.projectPath); + + // If we're uploading the project path as the output directory, make sure we don't accidentally upload any sensitive Wrangler files + if (autoConfigDetails.outputDir === autoConfigDetails.projectPath) { + maybeAppendWranglerToGitIgnoreLikeFile( + `${autoConfigDetails.projectPath}/.assetsignore` + ); + } + + const buildCommand = + configurationResults.buildCommandOverride ?? autoConfigDetails.buildCommand; + + if (buildCommand && runBuild) { + await context.runCommand( + buildCommand, + autoConfigDetails.projectPath, + "[build]" + ); + } + + return autoConfigSummary; +} + +/** + * Given a wrangler config object this function makes sure that the `nodejs_compat` flag is present + * in its `compatibility_flags` setting. + * + * Just to be sure the function also filters out any compatibility flag already present starting with `nodejs_` (e.g. `nodejs_als`) + * + * @param wranglerConfig The target wrangler config object + * @returns A copy of the config object where the `compatibility_flags` settings is assured to contain `nodejs_compat` + */ +function ensureNodejsCompatIsInConfig(wranglerConfig: RawConfig): RawConfig { + if (wranglerConfig.compatibility_flags?.includes("nodejs_compat")) { + return wranglerConfig; + } + + return { + ...wranglerConfig, + compatibility_flags: [ + ...(wranglerConfig.compatibility_flags?.filter( + (flag) => !flag.startsWith("nodejs_") + ) ?? []), + "nodejs_compat", + ], + }; +} + +/** + * Saves the a wrangler.jsonc file for the current project potentially combining new values to the potential + * pre-existing wrangler config file generated by the framework's CLI + * + * @param projectPath The project's path + * @param baseWranglerConfig The wrangler config to use + */ +async function saveWranglerJsonc( + projectPath: string, + wranglerConfig: RawConfig +): Promise { + let existingWranglerConfig: RawConfig = {}; + + const wranglerConfigPath = getDirWranglerJsonConfigPath(projectPath); + if (wranglerConfigPath) { + const existingContent = await readFile(wranglerConfigPath, "utf8"); + existingWranglerConfig = parseJSONC( + existingContent, + wranglerConfigPath + ) as RawConfig; + } + + await writeFile( + resolve(projectPath, "wrangler.jsonc"), + JSON.stringify( + { + ...existingWranglerConfig, + ...wranglerConfig, + }, + null, + 2 + ) + "\n" + ); +} + +/** + * Builds a summary of all operations that autoconfig will (or did) perform, + * including package installation, package.json script updates, wrangler config + * creation, and framework-specific configuration. + * + * @param autoConfigDetails - The detected project details. + * @param wranglerConfigToWrite - The wrangler config object to write, or `null` if not applicable. + * @param projectCommands - The build, deploy, and version commands for the project. + * @param context - The autoconfig context providing logger and other dependencies. + * @param packageJsonScriptsOverrides - Optional overrides for package.json script entries. + * @returns A summary object describing all planned operations. + */ +export async function buildOperationsSummary( + autoConfigDetails: AutoConfigDetailsForNonConfiguredProject & { + outputDir: NonNullable; + }, + wranglerConfigToWrite: RawConfig | null, + projectCommands: { + build?: string; + deploy: string; + version?: string; + }, + context: AutoConfigContext, + packageJsonScriptsOverrides?: PackageJsonScriptsOverrides +): Promise { + const { logger } = context; + logger.log(""); + + const summary: AutoConfigSummary = { + wranglerInstall: false, + scripts: {}, + ...(wranglerConfigToWrite !== null + ? { + wranglerConfig: wranglerConfigToWrite, + } + : {}), + outputDir: autoConfigDetails.outputDir, + frameworkId: autoConfigDetails.framework.id, + buildCommand: projectCommands.build, + deployCommand: projectCommands.deploy, + versionCommand: projectCommands.version, + }; + + if (autoConfigDetails.packageJson) { + // If there is a package.json file we will want to install wrangler + summary.wranglerInstall = true; + + logger.log("📦 Install packages:"); + logger.log(` - wrangler (devDependency)`); + logger.log(""); + + summary.scripts = { + deploy: + packageJsonScriptsOverrides?.deploy ?? + (autoConfigDetails.buildCommand + ? `${autoConfigDetails.buildCommand} && wrangler deploy` + : `wrangler deploy`), + preview: + packageJsonScriptsOverrides?.preview ?? + (autoConfigDetails.buildCommand + ? `${autoConfigDetails.buildCommand} && wrangler dev` + : `wrangler dev`), + }; + + const containsServerSideCode = + // If there is an entrypoint then we know that there is server side code + !!wranglerConfigToWrite?.main; + + if ( + // If there is no server side code, then there is no need to add the cf-typegen script + containsServerSideCode && + usesTypescript(autoConfigDetails.projectPath) && + !("cf-typegen" in (autoConfigDetails.packageJson.scripts ?? {})) + ) { + summary.scripts["cf-typegen"] = + packageJsonScriptsOverrides?.typegen ?? "wrangler types"; + } + + logger.log("📝 Update package.json scripts:"); + for (const [name, script] of Object.entries(summary.scripts)) { + logger.log(` - "${name}": "${script}"`); + } + logger.log(""); + } + + if (wranglerConfigToWrite) { + const wranglerConfigPath = resolve( + autoConfigDetails.projectPath, + "wrangler.jsonc" + ); + const configExists = existsSync(wranglerConfigPath); + logger.log( + configExists ? "📄 Update wrangler.jsonc:" : "📄 Create wrangler.jsonc:" + ); + logger.log( + " " + + JSON.stringify(wranglerConfigToWrite, null, 2).replace(/\n/g, "\n ") + ); + logger.log(""); + } + + if ( + autoConfigDetails.framework && + !(autoConfigDetails.framework instanceof Static) && + !autoConfigDetails.framework.isConfigured(autoConfigDetails.projectPath) + ) { + summary.frameworkConfiguration = + autoConfigDetails.framework.configurationDescription ?? + `Configuring project for ${autoConfigDetails.framework.name}`; + + logger.log(`🛠️ ${summary.frameworkConfiguration}`); + logger.log(""); + } + + return summary; +} + +/** + * Gets the path to the wrangler config file, in jsonc or json format, if present in a target directory. + * + * @param dir The target directory + * @returns The path to the wrangler config file if present, `undefined` otherwise + */ +function getDirWranglerJsonConfigPath(dir: string): string | undefined { + const filePathJsonC = resolve(dir, "wrangler.jsonc"); + if (existsSync(filePathJsonC)) { + return filePathJsonC; + } + + const filePathJson = resolve(dir, "wrangler.json"); + if (existsSync(filePathJson)) { + return filePathJson; + } + + return undefined; +} diff --git a/packages/autoconfig/src/types.ts b/packages/autoconfig/src/types.ts new file mode 100644 index 0000000..3cd97d0 --- /dev/null +++ b/packages/autoconfig/src/types.ts @@ -0,0 +1,80 @@ +import type { AutoConfigContext } from "./context"; +import type { Framework } from "./frameworks/framework-class"; +import type { PackageManager } from "@cloudflare/workers-utils"; +import type { PackageJSON, RawConfig } from "@cloudflare/workers-utils"; + +/** Makes the specified keys of T optional. */ +type Optional = Omit & Partial>; + +type AutoConfigDetailsBase = { + /** The name of the worker */ + workerName: string; + /** The path to the project (defaults to cwd) */ + projectPath: string; + /** The content of the project's package.json file (if any) */ + packageJson?: PackageJSON; + /** Whether the project is already configured (no autoconfig required) */ + configured: boolean; + /** Details about the detected framework. It can be a JS framework or 'Static' if no actual JS framework is used. */ + framework: Framework; + /** The build command used to build the project (if any) */ + buildCommand?: string; + /** The output directory (if no framework is used, points to the raw asset files) */ + outputDir: string; + /** The detected package manager for the project */ + packageManager: PackageManager; + /** Whether the current path is at the root of a workspace */ + isWorkspaceRoot?: boolean; +}; + +export type AutoConfigDetailsForConfiguredProject = Optional< + AutoConfigDetailsBase, + // If AutoConfig detects that the project is already configured it's unnecessary to check + // what framework is being used nor the output directory, so in this case thee fields are optional + "framework" | "outputDir" +> & { + configured: true; +}; + +export type AutoConfigDetailsForNonConfiguredProject = AutoConfigDetailsBase & { + configured: false; +}; + +export type AutoConfigDetails = + | AutoConfigDetailsForConfiguredProject + | AutoConfigDetailsForNonConfiguredProject; + +export type AutoConfigOptions = { + /** The autoconfig context providing logger, dialogs, and other dependencies. */ + context: AutoConfigContext; + /** Whether to run autoconfig without actually applying any filesystem modification (default: false) */ + dryRun?: boolean; + /** + * Whether the build command should be run (default: true) + * + * Note: When `dryRun` is `true` the build command is never run. + */ + runBuild?: boolean; + /** + * Whether the confirmation prompts should be skipped (default: false) + * + * Note: When `dryRun` is `true` the the confirmation prompts are always skipped. + */ + skipConfirmations?: boolean; + /** + * Whether to install Wrangler during autoconfig + */ + enableWranglerInstallation?: boolean; +}; + +export type AutoConfigSummary = { + scripts: Record; + wranglerInstall: boolean; + wranglerConfig?: RawConfig; + frameworkConfiguration?: string; + outputDir: string; + frameworkId?: string; + buildCommand?: string; + deployCommand?: string; + versionCommand?: string; +}; diff --git a/packages/autoconfig/src/uses-typescript.ts b/packages/autoconfig/src/uses-typescript.ts new file mode 100644 index 0000000..17b6feb --- /dev/null +++ b/packages/autoconfig/src/uses-typescript.ts @@ -0,0 +1,6 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export function usesTypescript(projectPath: string) { + return existsSync(join(projectPath, `tsconfig.json`)); +} diff --git a/packages/autoconfig/tests/details/confirm-auto-config-details.test.ts b/packages/autoconfig/tests/details/confirm-auto-config-details.test.ts new file mode 100644 index 0000000..f3550ef --- /dev/null +++ b/packages/autoconfig/tests/details/confirm-auto-config-details.test.ts @@ -0,0 +1,248 @@ +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { describe, test, vi } from "vitest"; +import { confirmAutoConfigDetails } from "../../src/details"; +import { Astro } from "../../src/frameworks/astro"; +import { Static } from "../../src/frameworks/static"; +import { createMockContext } from "../helpers/mock-context"; + +describe("autoconfig details - confirmAutoConfigDetails()", () => { + describe("interactive mode", () => { + test("no modifications applied", async ({ expect }) => { + const noModifyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(false), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + const updatedAutoConfigDetails = await confirmAutoConfigDetails( + { + workerName: "worker-name", + buildCommand: "npm run build", + projectPath: "", + configured: false, + framework: new Static({ id: "static", name: "Static" }), + outputDir: "./public", + packageManager: NpmPackageManager, + }, + noModifyContext + ); + + expect(updatedAutoConfigDetails).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "configured": false, + "framework": Static { + "configurationDescription": undefined, + "id": "static", + "name": "Static", + }, + "outputDir": "./public", + "packageManager": { + "dlx": [ + "npx", + ], + "lockFiles": [ + "package-lock.json", + ], + "npx": "npx", + "type": "npm", + }, + "projectPath": "", + "workerName": "worker-name", + } + `); + }); + + test("settings can be updated in a plain static site without a framework nor a build script", async ({ + expect, + }) => { + const modifyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi + .fn() + .mockResolvedValueOnce("new-name") + .mockResolvedValueOnce("./_public_") + .mockResolvedValueOnce("npm run app:build"), + select: vi.fn().mockResolvedValue("static"), + }, + }); + + const updatedAutoConfigDetails = await confirmAutoConfigDetails( + { + workerName: "my-worker", + buildCommand: "npm run build", + outputDir: "", + projectPath: "", + configured: false, + framework: new Static({ id: "static", name: "Static" }), + packageManager: NpmPackageManager, + }, + modifyContext + ); + expect(updatedAutoConfigDetails).toMatchInlineSnapshot(` + { + "buildCommand": "npm run app:build", + "configured": false, + "framework": Static { + "configurationDescription": undefined, + "id": "static", + "name": "Static", + }, + "outputDir": "./_public_", + "packageManager": { + "dlx": [ + "npx", + ], + "lockFiles": [ + "package-lock.json", + ], + "npx": "npx", + "type": "npm", + }, + "projectPath": "", + "workerName": "new-name", + } + `); + }); + + test("settings can be updated in a static app using a framework", async ({ + expect, + }) => { + const modifyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi + .fn() + .mockResolvedValueOnce("my-astro-worker") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("npm run build"), + select: vi.fn().mockResolvedValue("astro"), + }, + }); + + const updatedAutoConfigDetails = await confirmAutoConfigDetails( + { + workerName: "my-astro-site", + buildCommand: "astro build", + framework: new Astro({ id: "astro", name: "Astro" }), + outputDir: "", + projectPath: "", + configured: false, + packageManager: NpmPackageManager, + }, + modifyContext + ); + expect(updatedAutoConfigDetails).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "configured": false, + "framework": Astro { + "configurationDescription": "Configuring project for Astro with "astro add cloudflare"", + "id": "astro", + "name": "Astro", + }, + "outputDir": "", + "packageManager": { + "dlx": [ + "npx", + ], + "lockFiles": [ + "package-lock.json", + ], + "npx": "npx", + "type": "npm", + }, + "projectPath": "", + "workerName": "my-astro-worker", + } + `); + }); + + test("framework can be changed from a detected framework to another", async ({ + expect, + }) => { + const modifyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi + .fn() + .mockResolvedValueOnce("my-nuxt-worker") + .mockResolvedValueOnce("./dist") + .mockResolvedValueOnce("npm run build"), + select: vi.fn().mockResolvedValue("nuxt"), + }, + }); + + const updatedAutoConfigDetails = await confirmAutoConfigDetails( + { + workerName: "my-astro-site", + buildCommand: "astro build", + framework: new Astro({ id: "astro", name: "Astro" }), + outputDir: "", + projectPath: "", + configured: false, + packageManager: NpmPackageManager, + }, + modifyContext + ); + + expect(updatedAutoConfigDetails.framework?.id).toBe("nuxt"); + expect(updatedAutoConfigDetails.framework?.name).toBe("Nuxt"); + }); + }); + + describe("non-interactive mode", () => { + test("no modifications are applied in non-interactive", async ({ + expect, + }) => { + const nonInteractiveContext = createMockContext({ + isNonInteractiveOrCI: () => true, + dialogs: { + confirm: vi.fn().mockResolvedValue(false), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + + const updatedAutoConfigDetails = await confirmAutoConfigDetails( + { + workerName: "worker-name", + buildCommand: "npm run build", + projectPath: "", + configured: false, + framework: new Static({ id: "static", name: "Static" }), + outputDir: "./public", + packageManager: NpmPackageManager, + }, + nonInteractiveContext + ); + + expect(updatedAutoConfigDetails).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "configured": false, + "framework": Static { + "configurationDescription": undefined, + "id": "static", + "name": "Static", + }, + "outputDir": "./public", + "packageManager": { + "dlx": [ + "npx", + ], + "lockFiles": [ + "package-lock.json", + ], + "npx": "npx", + "type": "npm", + }, + "projectPath": "", + "workerName": "worker-name", + } + `); + }); + }); +}); diff --git a/packages/autoconfig/tests/details/display-auto-config-details.test.ts b/packages/autoconfig/tests/details/display-auto-config-details.test.ts new file mode 100644 index 0000000..14dbff1 --- /dev/null +++ b/packages/autoconfig/tests/details/display-auto-config-details.test.ts @@ -0,0 +1,96 @@ +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { mockConsoleMethods } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { displayAutoConfigDetails } from "../../src/details"; +import { Static } from "../../src/frameworks/static"; +import { createMockContext } from "../helpers/mock-context"; +import type { Framework } from "../../src/frameworks"; + +describe("autoconfig details - displayAutoConfigDetails()", () => { + const std = mockConsoleMethods(); + const context = createMockContext(); + + it("should cleanly handle a case in which only the worker name has been detected", ({ + expect, + }) => { + displayAutoConfigDetails( + { + configured: false, + projectPath: process.cwd(), + workerName: "my-project", + framework: new Static({ id: "static", name: "Static" }), + outputDir: "./public", + packageManager: NpmPackageManager, + }, + context + ); + expect(std.out).toMatchInlineSnapshot( + ` + " + Detected Project Settings: + - Worker Name: my-project + - Framework: Static + - Output Directory: ./public + " + ` + ); + }); + + it("should display all the project settings provided by the details object", ({ + expect, + }) => { + displayAutoConfigDetails( + { + configured: false, + projectPath: process.cwd(), + workerName: "my-astro-app", + framework: { + name: "Astro", + id: "astro", + isConfigured: () => false, + configure: () => + ({ + wranglerConfig: {}, + }) satisfies ReturnType, + } as unknown as Framework, + buildCommand: "astro build", + outputDir: "dist", + packageManager: NpmPackageManager, + }, + context + ); + expect(std.out).toMatchInlineSnapshot(` + " + Detected Project Settings: + - Worker Name: my-astro-app + - Framework: Astro + - Build Command: astro build + - Output Directory: dist + " + `); + }); + + it("should omit the framework and build command entries when they are not part of the details object", ({ + expect, + }) => { + displayAutoConfigDetails( + { + configured: false, + projectPath: process.cwd(), + workerName: "my-site", + outputDir: "dist", + framework: new Static({ id: "static", name: "Static" }), + packageManager: NpmPackageManager, + }, + context + ); + expect(std.out).toMatchInlineSnapshot(` + " + Detected Project Settings: + - Worker Name: my-site + - Framework: Static + - Output Directory: dist + " + `); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/basic-framework-detection.test.ts b/packages/autoconfig/tests/details/framework-detection/basic-framework-detection.test.ts new file mode 100644 index 0000000..6f964c0 --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/basic-framework-detection.test.ts @@ -0,0 +1,49 @@ +import { writeFile } from "node:fs/promises"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; + +describe("detectFramework() / basic framework detection", () => { + runInTempDir(); + const context = createMockContext(); + + it("defaults to the static framework when no framework is detected", async ({ + expect, + }) => { + await writeFile( + "package-lock.json", + JSON.stringify({ lockfileVersion: 3 }) + ); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework.framework.id).toBe("static"); + }); + + it("detects astro when astro is in dependencies", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + expect(result.detectedFramework?.framework.name).toBe("Astro"); + }); + + it("includes buildCommand in detectedFramework when available", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.buildCommand).toBeDefined(); + expect(result.detectedFramework?.buildCommand).toContain("astro build"); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/lock-file-warning.test.ts b/packages/autoconfig/tests/details/framework-detection/lock-file-warning.test.ts new file mode 100644 index 0000000..15167c9 --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/lock-file-warning.test.ts @@ -0,0 +1,49 @@ +import { + mockConsoleMethods, + runInTempDir, + seed, +} from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; +const noLockFileWarning = + "No lock file has been detected in the current working directory. This might indicate that the project is part of a workspace."; + +describe("detectFramework() / lock file warning", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const context = createMockContext(); + + it("warns when no lock file is detected", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + }); + + await detectFramework(process.cwd(), context); + + expect(std.warn).toContain(noLockFileWarning); + }); + + it("does not warn for static projects without a lock file", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + }); + + await detectFramework(process.cwd(), context); + + expect(std.warn).not.toContain(noLockFileWarning); + }); + + it("does not warn when a lock file exists", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await detectFramework(process.cwd(), context); + + expect(std.warn).not.toContain(noLockFileWarning); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/multiple-frameworks-detected.test.ts b/packages/autoconfig/tests/details/framework-detection/multiple-frameworks-detected.test.ts new file mode 100644 index 0000000..3da13b8 --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/multiple-frameworks-detected.test.ts @@ -0,0 +1,193 @@ +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, describe, it, vi } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; + +describe("detectFramework() / multiple frameworks detected", () => { + runInTempDir(); + const context = createMockContext(); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("non-CI environment", () => { + it("returns the known framework when multiple are detected but only one is known", async ({ + expect, + }) => { + // gatsby is not in allKnownFrameworks, only astro is + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", gatsby: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); + + it("filters out Vite and returns the other known framework", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { next: "14", vite: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.framework.id).toBe("next"); + }); + + it("returns Waku (not Hono) when both Waku and Hono are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { waku: "0.21", hono: "4" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.framework.id).toBe("waku"); + }); + + it("returns Hydrogen (not React Router) when both Hydrogen and React Router are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { + "@shopify/hydrogen": "2024", + "@react-router/dev": "7", + "react-router": "7", + }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.detectedFramework?.framework.id).toBe("hydrogen"); + }); + + it("returns first framework without throwing when multiple unknown frameworks are detected", async ({ + expect, + }) => { + // Both gatsby and gridsome are unknown to wrangler + await seed({ + "package.json": JSON.stringify({ + dependencies: { gatsby: "5", gridsome: "1" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + // Should not throw even with multiple unknowns in non-CI mode + await expect( + detectFramework(process.cwd(), context) + ).resolves.toBeDefined(); + }); + }); + + describe("CI environment", () => { + const ciContext = createMockContext({ + isNonInteractiveOrCI: () => true, + }); + + it("throws MultipleFrameworksCIError when multiple known frameworks are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", nuxt: "3" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await expect( + detectFramework(process.cwd(), ciContext) + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [Error: Cloudflare's tooling was unable to automatically configure your project, since multiple frameworks were found: Astro, Nuxt. + + To fix this issue either: + - check your project's configuration to make sure that the target framework + is the only configured one and try again + - run \`wrangler setup\` locally to get an interactive user experience where + you can specify what framework you want to target + ] + ` + ); + }); + + it("throws MultipleFrameworksCIError when multiple unknown frameworks are detected", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { gatsby: "5", gridsome: "1" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + await expect(detectFramework(process.cwd(), ciContext)).rejects.toThrow( + /Cloudflare's tooling was unable to automatically configure your project, since multiple frameworks were found/ + ); + }); + + it("does not throw when Hydrogen and React Router are detected (Hydrogen is selected)", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { + "@shopify/hydrogen": "2024", + "@react-router/dev": "7", + "react-router": "7", + }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), ciContext); + + expect(result.detectedFramework?.framework.id).toBe("hydrogen"); + }); + + it("does not throw when Vite and another known framework are detected (Vite is filtered out)", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { astro: "5", vite: "5" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), ciContext); + + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); + + it("does not throw when Hono and another known framework are detected (Hono is filtered out)", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + dependencies: { "@tanstack/react-start": "1.132.0", hono: "4" }, + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), ciContext); + + expect(result.detectedFramework?.framework.id).toBe("tanstack-start"); + }); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts b/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts new file mode 100644 index 0000000..22887c4 --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/package-manager-detection.test.ts @@ -0,0 +1,71 @@ +import { + BunPackageManager, + NpmPackageManager, + PnpmPackageManager, + YarnPackageManager, +} from "@cloudflare/workers-utils"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; + +describe("detectFramework() / package manager detection", () => { + runInTempDir(); + const context = createMockContext(); + + it("detects npm when package-lock.json is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(NpmPackageManager); + }); + + it("detects pnpm when pnpm-lock.yaml is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "pnpm-lock.yaml": "lockfileVersion: '6.0'\n", + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(PnpmPackageManager); + }); + + it("detects yarn when yarn.lock is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "yarn.lock": "# yarn lockfile v1\n", + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(YarnPackageManager); + }); + + it("detects bun when bun.lock is present", async ({ expect }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "bun.lock": "", + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(BunPackageManager); + }); + + it("falls back to npm when no package manager lock file is present", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.packageManager).toStrictEqual(NpmPackageManager); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/pages-project-detection.test.ts b/packages/autoconfig/tests/details/framework-detection/pages-project-detection.test.ts new file mode 100644 index 0000000..188072c --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/pages-project-detection.test.ts @@ -0,0 +1,110 @@ +import { join } from "node:path"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it, vi } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; +import type { Config } from "@cloudflare/workers-utils"; + +describe("detectFramework() / Pages project detection", () => { + runInTempDir(); + const context = createMockContext(); + + it("returns Cloudflare Pages framework when pages_build_output_dir is set in wrangler config", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context, { + pages_build_output_dir: "./dist", + } as Config); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + expect(result.detectedFramework?.dist).toBe("./dist"); + }); + + it("returns Cloudflare Pages framework when the pages cache file exists", async ({ + expect, + }) => { + const cacheFolder = join(process.cwd(), ".cache"); + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + [join(cacheFolder, "pages.json")]: JSON.stringify({ + account_id: "test-account", + }), + }); + + const cacheContext = createMockContext({ + getCacheFolder: () => cacheFolder, + }); + + const result = await detectFramework(process.cwd(), cacheContext); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + }); + + it("returns Cloudflare Pages when a functions/ directory exists, no framework is detected, and the user confirms", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + const confirmContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + + const result = await detectFramework(process.cwd(), confirmContext); + + expect(result.detectedFramework?.framework.id).toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.name).toBe("Cloudflare Pages"); + }); + + it("does not return Cloudflare Pages when the user denies the functions/ directory prompt", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({}), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + const denyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(false), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + + const result = await detectFramework(process.cwd(), denyContext); + + expect(result.detectedFramework?.framework.id).not.toBe("cloudflare-pages"); + }); + + it("does not return Cloudflare Pages when a functions/ directory exists alongside a detected framework", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ dependencies: { astro: "5" } }), + "functions/hello.js": `export function onRequest() { return new Response("hi"); }`, + }); + + const result = await detectFramework(process.cwd(), context); + + // Astro is detected, so Pages detection via functions/ is skipped + expect(result.detectedFramework?.framework.id).not.toBe("cloudflare-pages"); + expect(result.detectedFramework?.framework.id).toBe("astro"); + }); +}); diff --git a/packages/autoconfig/tests/details/framework-detection/workspace-root-handling.test.ts b/packages/autoconfig/tests/details/framework-detection/workspace-root-handling.test.ts new file mode 100644 index 0000000..7da594d --- /dev/null +++ b/packages/autoconfig/tests/details/framework-detection/workspace-root-handling.test.ts @@ -0,0 +1,59 @@ +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { detectFramework } from "../../../src/details/framework-detection"; +import { createMockContext } from "../../helpers/mock-context"; + +describe("detectFramework() / workspace root handling", () => { + runInTempDir(); + const context = createMockContext(); + + it("sets isWorkspaceRoot to false for regular (non-monorepo) projects", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ name: "my-app" }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.isWorkspaceRoot).toBe(false); + }); + + it("sets isWorkspaceRoot to true when the workspace root is itself a workspace package", async ({ + expect, + }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n - '.'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*", "."], + }), + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + }); + + const result = await detectFramework(process.cwd(), context); + + expect(result.isWorkspaceRoot).toBe(true); + }); + + it("throws UserError when run from a workspace root that does not include the root as a package", async ({ + expect, + }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*"], + }), + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + "packages/my-app/index.html": "

Hello World

", + }); + + await expect( + detectFramework(process.cwd(), context) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The Cloudflare application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again.]` + ); + }); +}); diff --git a/packages/autoconfig/tests/details/get-details-for-auto-config.test.ts b/packages/autoconfig/tests/details/get-details-for-auto-config.test.ts new file mode 100644 index 0000000..9d1190f --- /dev/null +++ b/packages/autoconfig/tests/details/get-details-for-auto-config.test.ts @@ -0,0 +1,562 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + mockConsoleMethods, + runInTempDir, + seed, +} from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, describe, it, vi } from "vitest"; +import * as details from "../../src/details"; +import { createMockContext } from "../helpers/mock-context"; +import type { Config } from "@cloudflare/workers-utils"; + +describe("autoconfig details - getDetailsForAutoConfig()", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const context = createMockContext(); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should set configured: true if a configPath exists", async ({ + expect, + }) => { + await expect( + details.getDetailsForAutoConfig({ + wranglerConfig: { configPath: "/tmp" } as Config, + context, + }) + ).resolves.toMatchObject({ configured: true }); + }); + + // Check that Astro is detected. We don't want to duplicate the tests of @netlify/build-info + // by exhaustively checking every possible combination + it.for(["npm", "pnpm"] as const)( + "should perform basic framework detection (using %s)", + async (pm, { expect }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + astro: "5", + }, + }) + ); + + // Create the appropriate lockfile so @netlify/build-info detects the package manager + if (pm === "pnpm") { + await writeFile("pnpm-lock.yaml", "lockfileVersion: 6.0"); + } else { + await writeFile( + "package-lock.json", + JSON.stringify({ lockfileVersion: 3 }) + ); + } + + await expect( + details.getDetailsForAutoConfig({ context }) + ).resolves.toMatchObject({ + buildCommand: pm === "pnpm" ? "pnpm astro build" : "npx astro build", + configured: false, + outputDir: "dist", + packageJson: { + dependencies: { + astro: "5", + }, + }, + }); + } + ); + + it("should select the known framework when multiple frameworks are detected but only one is known", async ({ + expect, + }) => { + // Gatsby is not in allKnownFrameworks, so only Astro should be considered + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + astro: "5", + gatsby: "5", + }, + }) + ); + + const result = await details.getDetailsForAutoConfig({ context }); + + // Should select Astro since it's the only known framework + expect(result.framework?.id).toBe("astro"); + expect(result.framework?.name).toBe("Astro"); + }); + + it("should bail when run in the root of a workspace", async ({ expect }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*"], + }), + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + "packages/my-app/index.html": "

Hello World

", + }); + + await expect( + details.getDetailsForAutoConfig({ context }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The Cloudflare application detection logic has been run in the root of a workspace instead of targeting a specific project. Change your working directory to one of the applications in the workspace and try again.]` + ); + }); + + it("should not bail when run in the root of a workspace if the root is included as a workspace package", async ({ + expect, + }) => { + await seed({ + "pnpm-workspace.yaml": "packages:\n - 'packages/*'\n - '.'\n", + "package.json": JSON.stringify({ + name: "my-workspace", + workspaces: ["packages/*", "."], + }), + "index.html": "

Hello World

", + "packages/my-app/package.json": JSON.stringify({ name: "my-app" }), + "packages/my-app/index.html": "

Hello World

", + }); + + const result = await details.getDetailsForAutoConfig({ context }); + + expect(result.isWorkspaceRoot).toBe(true); + expect(result.framework?.id).toBe("static"); + }); + + it("should set isWorkspaceRoot to false for non-workspace projects", async ({ + expect, + }) => { + await seed({ + "package.json": JSON.stringify({ + name: "my-app", + }), + "package-lock.json": JSON.stringify({ lockfileVersion: 3 }), + "index.html": "

Hello World

", + }); + + const result = await details.getDetailsForAutoConfig({ context }); + + expect(result.isWorkspaceRoot).toBe(false); + }); + + it("should warn when no lock file is detected (project may be inside a workspace)", async ({ + expect, + }) => { + // Create a project without a lock file - simulating a project inside a workspace + // where the lock file is at the workspace root + await seed({ + "package.json": JSON.stringify({ + name: "my-app", + dependencies: { astro: "6" }, + }), + "index.html": "

Hello World

", + }); + + await details.getDetailsForAutoConfig({ context }); + + expect(std.warn).toContain( + "No lock file has been detected in the current working directory." + ); + expect(std.warn).toContain("project is part of a workspace"); + }); + + it("should use npm build instead of framework build if present", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + scripts: { + build: "echo build", + }, + dependencies: { + astro: "5", + }, + }) + ); + + await expect( + details.getDetailsForAutoConfig({ context }) + ).resolves.toMatchObject({ + buildCommand: "npm run build", + }); + }); + + it("an error should be thrown if no output dir can be detected", async ({ + expect, + }) => { + await expect( + details.getDetailsForAutoConfig({ context }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not detect a directory containing static files (e.g. html, css and js) for the project]` + ); + }); + + it("outputDir should be set to cwd if an index.html file exists", async ({ + expect, + }) => { + await writeFile("index.html", `

Hello World

`); + + await expect( + details.getDetailsForAutoConfig({ context }) + ).resolves.toMatchObject({ + outputDir: ".", + }); + }); + + it("outputDir should find first child directory with an index.html file", async ({ + expect, + }) => { + await seed({ + "public/index.html": `

Hello World

`, + "random/index.html": `

Hello World

`, + }); + + await expect( + details.getDetailsForAutoConfig({ context }) + ).resolves.toMatchObject({ + outputDir: "public", + }); + }); + + it("outputDir should prioritize the project directory over its child directories", async ({ + expect, + }) => { + await seed({ + "index.html": `

Hello World

`, + "public/index.html": `

Hello World

`, + }); + + await expect( + details.getDetailsForAutoConfig({ context }) + ).resolves.toMatchObject({ + outputDir: ".", + }); + }); + + const workerNamesToTest = [ + { rawName: "my-project-1", normalizedName: "my-project-1" }, + { + rawName: "--my-other-project%1_", + normalizedName: "my-other-project-1", + }, + { + rawName: "x".repeat(100), + normalizedName: "x".repeat(63), + }, + ]; + + it.for(workerNamesToTest)( + "should use the directory name as the worker name for a plain static site, normalizing it if needed (%s)", + async ( + { rawName: dirname, normalizedName: expectedWorkerName }, + { expect } + ) => { + await seed({ + [`./${dirname}/index.html`]: "

Hello World

", + }); + await expect( + details.getDetailsForAutoConfig({ + projectPath: `./${dirname}`, + context, + }) + ).resolves.toMatchObject({ + workerName: expectedWorkerName, + }); + } + ); + + it.for(workerNamesToTest)( + "should use the project name from the package.json file when available as the worker name, normalizing it if needed (%s)", + async ( + { rawName: projectName, normalizedName: expectedWorkerName }, + { expect } + ) => { + const dirname = `project-${randomUUID()}`; + await seed({ + [`./${dirname}/package.json`]: JSON.stringify({ name: projectName }), + [`./${dirname}/index.html`]: "

Hello World

", + }); + await expect( + details.getDetailsForAutoConfig({ + projectPath: `./${dirname}`, + context, + }) + ).resolves.toMatchObject({ + workerName: expectedWorkerName, + }); + } + ); + + it("WRANGLER_CI_OVERRIDE_NAME, when set should override the worker name", async ({ + expect, + }) => { + vi.stubEnv("WRANGLER_CI_OVERRIDE_NAME", "overridden-worker-name"); + + await seed({ + "./my-project/index.html": "

Hello World

", + }); + await expect( + details.getDetailsForAutoConfig({ + projectPath: `./my-project`, + context, + }) + ).resolves.toMatchObject({ + workerName: "overridden-worker-name", + }); + }); + + describe("Pages project detection", () => { + it("should detect Pages project when pages_build_output_dir is set in wrangler config", async ({ + expect, + }) => { + await seed({ + "public/index.html": `

Hello World

`, + }); + + const result = await details.getDetailsForAutoConfig({ + wranglerConfig: { + configPath: "/tmp/wrangler.toml", + pages_build_output_dir: "./dist", + } as Config, + context, + }); + + expect(result.configured).toBe(false); + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + }); + + it("should detect Pages project when pages.json cache file exists", async ({ + expect, + }) => { + const cacheFolder = join(process.cwd(), ".cache"); + await seed({ + "public/index.html": `

Hello World

`, + [join(cacheFolder, "pages.json")]: JSON.stringify({ + account_id: "test-account", + }), + }); + + const cacheContext = createMockContext({ + getCacheFolder: () => cacheFolder, + }); + + const result = await details.getDetailsForAutoConfig({ + context: cacheContext, + }); + + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + }); + + it("should detect Pages project when functions directory exists, no framework is detected and the user confirms that it is", async ({ + expect, + }) => { + await seed({ + "public/index.html": `

Hello World

`, + "functions/hello.js": ` + export function onRequest(context) { + return new Response("Hello, world!"); + } + `, + }); + + const confirmContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + + const result = await details.getDetailsForAutoConfig({ + context: confirmContext, + }); + + expect(result.framework?.id).toBe("cloudflare-pages"); + expect(result.framework?.name).toBe("Cloudflare Pages"); + }); + + it("should not detect Pages project when the user denies that, even if the functions directory exists and no framework is detected", async ({ + expect, + }) => { + await seed({ + "public/index.html": `

Hello World

`, + "functions/hello.js": ` + export function onRequest(context) { + return new Response("Hello, world!"); + } + `, + }); + + const denyContext = createMockContext({ + dialogs: { + confirm: vi.fn().mockResolvedValue(false), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + }); + + const result = await details.getDetailsForAutoConfig({ + context: denyContext, + }); + + expect(result.framework?.id).toBe("static"); + expect(result.framework?.name).toBe("Static"); + }); + + it("should not detect Pages project when functions directory exists but a framework is detected", async ({ + expect, + }) => { + await seed({ + "functions/hello.js": + "export const myFun = () => { console.log('Hello!'); };", + "package.json": JSON.stringify({ + dependencies: { + astro: "5", + }, + }), + }); + + const result = await details.getDetailsForAutoConfig({ context }); + + // Should detect Astro, not Pages + expect(result.framework?.id).toBe("astro"); + }); + }); + + describe("multiple frameworks detected", () => { + describe("local environment (non-CI)", () => { + it("should return a single framework when multiple frameworks are detected", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + astro: "5", + "@angular/core": "18", + }, + }) + ); + + const result = await details.getDetailsForAutoConfig({ context }); + + // Should return a framework (either astro or angular) + expect(result.framework).toBeDefined(); + expect(["astro", "angular"]).toContain(result.framework?.id); + }); + }); + + describe("CI environment", () => { + const ciContext = createMockContext({ + isNonInteractiveOrCI: () => true, + }); + + it("should throw MultipleFrameworksCIError when multiple known frameworks are detected in CI", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + astro: "5", + nuxt: "3", + }, + }) + ); + + await expect( + details.getDetailsForAutoConfig({ context: ciContext }) + ).rejects.toThrow( + /Cloudflare's tooling was unable to automatically configure your project, since multiple frameworks were found/ + ); + }); + + it("should NOT throw when Vite and another known framework are detected in CI (Vite is filtered out)", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + astro: "5", + vite: "5", + }, + }) + ); + + const result = await details.getDetailsForAutoConfig({ + context: ciContext, + }); + expect(result.framework?.id).toBe("astro"); + }); + + it("should throw MultipleFrameworksCIError when multiple unknown frameworks are detected in CI", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + gatsby: "5", + gridsome: "1", + }, + }) + ); + + await expect( + details.getDetailsForAutoConfig({ context: ciContext }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [Error: Cloudflare's tooling was unable to automatically configure your project, since multiple frameworks were found: Gatsby, Gridsome. + + To fix this issue either: + - check your project's configuration to make sure that the target framework + is the only configured one and try again + - run \`wrangler setup\` locally to get an interactive user experience where + you can specify what framework you want to target + ] + ` + ); + }); + }); + + it("should return non-Vite framework when Vite and another known framework are detected", async ({ + expect, + }) => { + await writeFile( + "package.json", + JSON.stringify({ + dependencies: { + next: "14", + vite: "5", + }, + }) + ); + + const result = await details.getDetailsForAutoConfig({ context }); + expect(result.framework?.id).toBe("next"); + }); + + it("should fallback to static framework when no frameworks detected", async ({ + expect, + }) => { + await seed({ + "index.html": "

Hello World

", + "package.json": JSON.stringify({}), + }); + + const result = await details.getDetailsForAutoConfig({ context }); + + expect(result.framework?.id).toBe("static"); + }); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/angular.test.ts b/packages/autoconfig/tests/frameworks/angular.test.ts new file mode 100644 index 0000000..b5aa1cf --- /dev/null +++ b/packages/autoconfig/tests/frameworks/angular.test.ts @@ -0,0 +1,445 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, it, vi } from "vitest"; +import { Angular } from "../../src/frameworks/angular"; +import { createMockContext } from "../helpers/mock-context"; +import type { AutoConfigFrameworkPackageInfo } from "../../src/frameworks"; +const context = createMockContext(); + +const BASE_OPTIONS = { + projectPath: process.cwd(), + workerName: "my-angular-app", + outputDir: "dist/my-angular-app/", + dryRun: false, + packageManager: NpmPackageManager, + isWorkspaceRoot: false, + context, +}; + +const ANGULAR_PACKAGE_INFO: AutoConfigFrameworkPackageInfo = { + name: "@angular/core", + minimumVersion: "19.0.0", + maximumKnownMajorVersion: "22", +}; + +async function mockAngularCoreVersion(version: string) { + const pkgDir = resolve("node_modules/@angular/core"); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + resolve(pkgDir, "package.json"), + JSON.stringify({ name: "@angular/core", version }) + ); +} + +describe("Angular framework configure()", () => { + runInTempDir(); + + let installSpy: ReturnType; + + beforeEach(() => { + installSpy = vi + .spyOn(cliPackages, "installPackages") + .mockImplementation(async () => {}); + }); + + describe("non-SSR (SPA) project", () => { + beforeEach(async () => { + /** Minimal angular.json for a non-SSR (SPA) project — no `ssr` field */ + const NON_SSR_ANGULAR_JSON = { + projects: { + "my-angular-app": { + architect: { + build: { + options: { + browser: "src/main.ts", + tsConfig: "tsconfig.app.json", + assets: [{ glob: "**/*", input: "public" }], + styles: ["src/styles.css"], + }, + }, + }, + }, + }, + }; + + await writeFile( + resolve("angular.json"), + JSON.stringify(NON_SSR_ANGULAR_JSON, null, 2) + ); + await mkdir(resolve("src"), { recursive: true }); + }); + + it("returns assets-only wranglerConfig", async ({ expect }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + const result = await framework.configure(BASE_OPTIONS); + + expect(result.wranglerConfig).toEqual({ + assets: { + directory: "dist/my-angular-app/", + }, + }); + expect(result.wranglerConfig).not.toHaveProperty("main"); + }); + + it("sets configurationDescription for SPA", async ({ expect }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + await framework.configure(BASE_OPTIONS); + + expect(framework.configurationDescription).toBe( + "Configuring Angular SPA project (assets only)" + ); + }); + + it("does not install additional dependencies", async ({ expect }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + await framework.configure(BASE_OPTIONS); + + expect(installSpy).not.toHaveBeenCalled(); + }); + + it("does not create src/server.ts", async ({ expect }) => { + const { existsSync } = await import("node:fs"); + const framework = new Angular({ id: "angular", name: "Angular" }); + await framework.configure(BASE_OPTIONS); + + expect(existsSync(resolve("src/server.ts"))).toBe(false); + }); + + it("does not modify angular.json", async ({ expect }) => { + const { readFileSync } = await import("node:fs"); + const framework = new Angular({ id: "angular", name: "Angular" }); + await framework.configure(BASE_OPTIONS); + + const angularJson = JSON.parse( + readFileSync(resolve("angular.json"), "utf8") + ); + // outputMode and outputPath should not have been set + expect( + angularJson.projects["my-angular-app"].architect.build.options + .outputMode + ).toBeUndefined(); + expect( + angularJson.projects["my-angular-app"].architect.build.options + .outputPath + ).toBeUndefined(); + }); + + it("skips side effects in dryRun mode", async ({ expect }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + const result = await framework.configure({ + ...BASE_OPTIONS, + dryRun: true, + }); + + expect(result.wranglerConfig).toEqual({ + assets: { directory: "dist/my-angular-app/" }, + }); + expect(installSpy).not.toHaveBeenCalled(); + }); + }); + + describe("project with ssr: false", () => { + beforeEach(async () => { + /** angular.json for a project with SSR explicitly set to false */ + const SSR_FALSE_ANGULAR_JSON = { + projects: { + "my-angular-app": { + architect: { + build: { + options: { + browser: "src/main.ts", + tsConfig: "tsconfig.app.json", + assets: [], + styles: [], + ssr: false, + }, + }, + }, + }, + }, + }; + + await writeFile( + resolve("angular.json"), + JSON.stringify(SSR_FALSE_ANGULAR_JSON, null, 2) + ); + await mkdir(resolve("src"), { recursive: true }); + }); + + it("returns assets-only wranglerConfig when ssr is false", async ({ + expect, + }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + const result = await framework.configure(BASE_OPTIONS); + + expect(result.wranglerConfig).toEqual({ + assets: { directory: "dist/my-angular-app/" }, + }); + expect(result.wranglerConfig).not.toHaveProperty("main"); + }); + + it("sets SPA configurationDescription when ssr is false", async ({ + expect, + }) => { + const framework = new Angular({ id: "angular", name: "Angular" }); + await framework.configure(BASE_OPTIONS); + + expect(framework.configurationDescription).toBe( + "Configuring Angular SPA project (assets only)" + ); + }); + }); + + describe("project with ssr: true (boolean shorthand)", () => { + beforeEach(async () => { + /** angular.json for a project with SSR enabled via the `true` boolean shorthand */ + const SSR_TRUE_ANGULAR_JSON = { + projects: { + "my-angular-app": { + architect: { + build: { + options: { + browser: "src/main.ts", + tsConfig: "tsconfig.app.json", + assets: [], + styles: [], + ssr: true, + }, + }, + }, + }, + }, + }; + + await writeFile( + resolve("angular.json"), + JSON.stringify(SSR_TRUE_ANGULAR_JSON, null, 2) + ); + await mkdir(resolve("src"), { recursive: true }); + }); + + it("returns SSR wranglerConfig without crashing", async ({ expect }) => { + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + const result = await framework.configure(BASE_OPTIONS); + + expect(result.wranglerConfig).toEqual({ + main: "./dist/server/server.mjs", + assets: { + binding: "ASSETS", + directory: "dist/my-angular-app/browser", + }, + }); + }); + + it("sets experimentalPlatform in angular.json when ssr was true (Angular <22)", async ({ + expect, + }) => { + const { readFileSync } = await import("node:fs"); + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + const angularJson = JSON.parse( + readFileSync(resolve("angular.json"), "utf8") + ); + const options = + angularJson.projects["my-angular-app"].architect.build.options; + // The boolean `true` should have been promoted to an object + expect(typeof options.ssr).toBe("object"); + expect(options.ssr.experimentalPlatform).toBe("neutral"); + }); + + it("sets platform in angular.json when ssr was true (Angular >=22)", async ({ + expect, + }) => { + const { readFileSync } = await import("node:fs"); + await mockAngularCoreVersion("22.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + const angularJson = JSON.parse( + readFileSync(resolve("angular.json"), "utf8") + ); + const options = + angularJson.projects["my-angular-app"].architect.build.options; + expect(typeof options.ssr).toBe("object"); + expect(options.ssr.platform).toBe("neutral"); + }); + }); + + describe("SSR project", () => { + beforeEach(async () => { + /** angular.json for a project with SSR enabled (object value) */ + const SSR_ANGULAR_JSON = { + projects: { + "my-angular-app": { + architect: { + build: { + options: { + browser: "src/main.ts", + tsConfig: "tsconfig.app.json", + assets: [], + styles: [], + ssr: { + entry: "src/server.ts", + }, + }, + }, + }, + }, + }, + }; + + await writeFile( + resolve("angular.json"), + JSON.stringify(SSR_ANGULAR_JSON, null, 2) + ); + await mkdir(resolve("src"), { recursive: true }); + }); + + it("returns SSR wranglerConfig with main and assets", async ({ + expect, + }) => { + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + const result = await framework.configure(BASE_OPTIONS); + + expect(result.wranglerConfig).toEqual({ + main: "./dist/server/server.mjs", + assets: { + binding: "ASSETS", + directory: "dist/my-angular-app/browser", + }, + }); + }); + + it("sets SSR configurationDescription", async ({ expect }) => { + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + expect(framework.configurationDescription).toBe( + "Configuring project for Angular" + ); + }); + + it("sets experimentalPlatform in angular.json (Angular <22)", async ({ + expect, + }) => { + const { readFileSync } = await import("node:fs"); + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + const angularJson = JSON.parse( + readFileSync(resolve("angular.json"), "utf8") + ); + const options = + angularJson.projects["my-angular-app"].architect.build.options; + expect(options.outputMode).toBe("server"); + expect(options.outputPath).toBe("dist"); + expect(options.ssr.experimentalPlatform).toBe("neutral"); + }); + + it("sets platform in angular.json (Angular >=22)", async ({ expect }) => { + const { readFileSync } = await import("node:fs"); + await mockAngularCoreVersion("22.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + const angularJson = JSON.parse( + readFileSync(resolve("angular.json"), "utf8") + ); + const options = + angularJson.projects["my-angular-app"].architect.build.options; + expect(options.outputMode).toBe("server"); + expect(options.outputPath).toBe("dist"); + expect(options.ssr.platform).toBe("neutral"); + }); + + it("creates src/server.ts", async ({ expect }) => { + const { existsSync } = await import("node:fs"); + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + expect(existsSync(resolve("src/server.ts"))).toBe(true); + }); + + it("installs additional dependencies", async ({ expect }) => { + await mockAngularCoreVersion("21.0.0"); + const framework = new Angular({ id: "angular", name: "Angular" }); + framework.validateFrameworkVersion( + process.cwd(), + ANGULAR_PACKAGE_INFO, + context + ); + await framework.configure(BASE_OPTIONS); + + expect(installSpy).toHaveBeenCalledWith( + NpmPackageManager.type, + ["xhr2"], + expect.objectContaining({ dev: true }) + ); + }); + + it("skips side effects in dryRun mode", async ({ expect }) => { + const { existsSync } = await import("node:fs"); + const framework = new Angular({ id: "angular", name: "Angular" }); + const result = await framework.configure({ + ...BASE_OPTIONS, + dryRun: true, + }); + + // Config is still returned + expect(result.wranglerConfig).toHaveProperty("main"); + // But side effects are skipped + expect(existsSync(resolve("src/server.ts"))).toBe(false); + expect(installSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-future-no-middleware.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-future-no-middleware.ts new file mode 100644 index 0000000..844e507 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-future-no-middleware.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, + future: { + v8_splitRouteModules: true, + }, +} satisfies Config; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-and-split.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-and-split.ts new file mode 100644 index 0000000..d4298c7 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-and-split.ts @@ -0,0 +1,8 @@ +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, + future: { + v8_middleware: true, + v8_splitRouteModules: true, + }, +} satisfies Config; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-false.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-false.ts new file mode 100644 index 0000000..4696f3a --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-false.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, + future: { + v8_middleware: false, + }, +} satisfies Config; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-true.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-true.ts new file mode 100644 index 0000000..dfc17a9 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-middleware-true.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-no-future.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-no-future.ts new file mode 100644 index 0000000..f6df076 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-no-future.ts @@ -0,0 +1,4 @@ +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, +} satisfies Config; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/config-plain-object-middleware.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-plain-object-middleware.ts new file mode 100644 index 0000000..44a6ef4 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/config-plain-object-middleware.ts @@ -0,0 +1,6 @@ +export default { + ssr: true, + future: { + v8_middleware: true, + }, +}; diff --git a/packages/autoconfig/tests/frameworks/fixtures/react-router/vite-config-basic.ts b/packages/autoconfig/tests/frameworks/fixtures/react-router/vite-config-basic.ts new file mode 100644 index 0000000..9e2760e --- /dev/null +++ b/packages/autoconfig/tests/frameworks/fixtures/react-router/vite-config-basic.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vite"; +export default defineConfig({ plugins: [] }); diff --git a/packages/autoconfig/tests/frameworks/get-framework-class.test.ts b/packages/autoconfig/tests/frameworks/get-framework-class.test.ts new file mode 100644 index 0000000..773fdcc --- /dev/null +++ b/packages/autoconfig/tests/frameworks/get-framework-class.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from "vitest"; +import { getFrameworkClassInstance } from "../../src/frameworks"; +import { NextJs } from "../../src/frameworks/next"; +import { NoOpFramework } from "../../src/frameworks/no-op"; +import { Static } from "../../src/frameworks/static"; + +describe("getFrameworkClassInstance()", () => { + it("should return a Static framework when frameworkId is unknown", ({ + expect, + }) => { + const framework = getFrameworkClassInstance("unknown-framework"); + + expect(framework).toBeInstanceOf(Static); + expect(framework.id).toBe("static"); + expect(framework.name).toBe("Static"); + }); + + it("should return a target framework when frameworkId is known and supported", ({ + expect, + }) => { + const framework = getFrameworkClassInstance("next"); + + expect(framework).toBeInstanceOf(NextJs); + expect(framework.id).toBe("next"); + expect(framework.name).toBe("Next.js"); + }); + + it("should return a NoOpFramework for an unsupported framework (hono)", ({ + expect, + }) => { + const framework = getFrameworkClassInstance("hono"); + + expect(framework).toBeInstanceOf(NoOpFramework); + expect(framework.id).toBe("hono"); + expect(framework.name).toBe("Hono"); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/is-framework-supported.test.ts b/packages/autoconfig/tests/frameworks/is-framework-supported.test.ts new file mode 100644 index 0000000..6747cb9 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/is-framework-supported.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "vitest"; +import { isFrameworkSupported } from "../../src/frameworks"; + +describe("isFrameworkSupported()", () => { + it("should return true for a supported framework id", ({ expect }) => { + expect(isFrameworkSupported("astro")).toBe(true); + }); + + it("should return false for a known but unsupported framework id", ({ + expect, + }) => { + expect(isFrameworkSupported("hono")).toBe(false); + }); + + it("should throw for an unknown framework id", ({ expect }) => { + expect(() => isFrameworkSupported("unknown-framework")).toThrow( + 'Unexpected unknown framework id: "unknown-framework"' + ); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/is-known-framework.test.ts b/packages/autoconfig/tests/frameworks/is-known-framework.test.ts new file mode 100644 index 0000000..97ffa2d --- /dev/null +++ b/packages/autoconfig/tests/frameworks/is-known-framework.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from "vitest"; +import { isKnownFramework } from "../../src/frameworks"; + +describe("isKnownFramework()", () => { + it("should return true for a known supported framework id", ({ expect }) => { + expect(isKnownFramework("astro")).toBe(true); + }); + + it("should return true for a known but unsupported framework id", ({ + expect, + }) => { + expect(isKnownFramework("hono")).toBe(true); + }); + + it('should return true for the "static" framework id', ({ expect }) => { + expect(isKnownFramework("static")).toBe(true); + }); + + it("should return false for an unknown framework id", ({ expect }) => { + expect(isKnownFramework("unknown-framework")).toBe(false); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/react-router.test.ts b/packages/autoconfig/tests/frameworks/react-router.test.ts new file mode 100644 index 0000000..6471727 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/react-router.test.ts @@ -0,0 +1,472 @@ +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, it, vi } from "vitest"; +import { + hasV8MiddlewareFlag, + ReactRouter, +} from "../../src/frameworks/react-router"; +import * as packagesUtils from "../../src/frameworks/utils/packages"; +import { createMockContext } from "../helpers/mock-context"; + +function fixture(name: string): string { + return readFileSync(join(__dirname, "fixtures/react-router", name), "utf-8"); +} + +vi.mock("../../src/frameworks/utils/vite-config", () => ({ + transformViteConfig: vi.fn(), +})); + +vi.mock("../../src/frameworks/utils/vite-plugin", () => ({ + installCloudflareVitePlugin: vi.fn(), +})); + +vi.mock("../../src/frameworks/utils/packages", () => ({ + getInstalledPackageVersion: vi.fn(), + isPackageInstalled: vi.fn(() => true), +})); + +const context = createMockContext(); + +function getBaseOptions() { + return { + projectPath: process.cwd(), + outputDir: "build/", + workerName: "my-react-router-app", + dryRun: false, + packageManager: NpmPackageManager, + isWorkspaceRoot: false, + context, + }; +} + +function createFramework(version: string): ReactRouter { + vi.mocked(packagesUtils.getInstalledPackageVersion).mockReturnValue(version); + + const framework = new ReactRouter({ + id: "react-router", + name: "React Router", + }); + + framework.validateFrameworkVersion( + ".", + { + name: "react-router", + minimumVersion: "7.0.0", + maximumKnownMajorVersion: "8", + }, + context + ); + + return framework; +} + +describe("hasV8MiddlewareFlag()", () => { + runInTempDir(); + + it("returns false when no config file exists", ({ expect }) => { + expect(hasV8MiddlewareFlag(process.cwd())).toBe(false); + }); + + it("returns false when config has no future block", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(false); + }); + + it("returns false when future block does not contain v8_middleware", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-future-no-middleware.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(false); + }); + + it("returns true when v8_middleware is set to true", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-true.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(true); + }); + + it("returns false when v8_middleware is set to false", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-false.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(false); + }); + + it("handles plain object export without satisfies", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-plain-object-middleware.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(true); + }); + + it("returns false when config file has syntax errors", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + "export default { this is not valid syntax" + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(false); + }); + + it("detects v8_middleware inside a `satisfies Config` expression", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-and-split.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd())).toBe(true); + }); +}); + +describe("React Router framework configure()", () => { + runInTempDir(); + + beforeEach(async () => { + vi.spyOn(cliPackages, "installPackages").mockImplementation(async () => {}); + + await mkdir(resolve("app"), { recursive: true }); + await writeFile(resolve("vite.config.ts"), fixture("vite-config-basic.ts")); + }); + + describe("workers/app.ts generation — without v8_middleware", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + }); + + it("creates workers/app.ts with AppLoadContext augmentation", async ({ + expect, + }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("workers/app.ts"), "utf-8"); + expect(content).toContain("AppLoadContext"); + expect(content).toContain("declare module"); + expect(content).toContain("cloudflare: { env, ctx }"); + expect(content).toContain("async fetch(request, env, ctx)"); + expect(content).toContain("satisfies ExportedHandler"); + }); + }); + + describe("workers/app.ts generation — with v8_middleware", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-true.ts") + ); + }); + + it("creates workers/app.ts without AppLoadContext augmentation", async ({ + expect, + }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("workers/app.ts"), "utf-8"); + expect(content).not.toContain("AppLoadContext"); + expect(content).not.toContain("declare module"); + expect(content).toContain( + 'import { createRequestHandler } from "react-router"' + ); + expect(content).toContain("requestHandler(request)"); + expect(content).toContain("satisfies ExportedHandler"); + }); + + it("creates workers/app.ts with a simple fetch handler", async ({ + expect, + }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("workers/app.ts"), "utf-8"); + expect(content).toContain("async fetch(request)"); + expect(content).not.toContain("env, ctx"); + expect(content).not.toContain("cloudflare: { env, ctx }"); + }); + }); + + describe("entry.server.tsx generation — without v8_middleware", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + }); + + it("creates entry.server.tsx with AppLoadContext", async ({ expect }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("app/entry.server.tsx"), "utf-8"); + expect(content).toContain("AppLoadContext"); + expect(content).toContain("_loadContext: AppLoadContext"); + expect(content).toContain( + 'import type { AppLoadContext, EntryContext } from "react-router"' + ); + }); + }); + + describe("entry.server.tsx generation — with v8_middleware", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-true.ts") + ); + }); + + it("creates entry.server.tsx without AppLoadContext", async ({ + expect, + }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("app/entry.server.tsx"), "utf-8"); + expect(content).not.toContain("AppLoadContext"); + expect(content).not.toContain("_loadContext"); + expect(content).toContain( + 'import type { EntryContext } from "react-router"' + ); + expect(content).toContain("ServerRouter"); + expect(content).toContain("renderToReadableStream"); + }); + }); + + describe("entry.server.tsx — existing file not overwritten", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-true.ts") + ); + }); + + it("does not overwrite existing entry.server.tsx", async ({ expect }) => { + const existingContent = "// existing entry server"; + await mkdir(resolve("app"), { recursive: true }); + await writeFile(resolve("app/entry.server.tsx"), existingContent); + + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("app/entry.server.tsx"), "utf-8"); + expect(content).toBe(existingContent); + }); + }); + + describe("react-router.config.ts transformation", () => { + it("adds v8_viteEnvironmentApi for React Router >= 7.10.0", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("react-router.config.ts"), "utf-8"); + expect(content).toContain("v8_viteEnvironmentApi: true"); + // Should NOT add other v8 flags + expect(content).not.toContain("v8_middleware"); + expect(content).not.toContain("v8_splitRouteModules"); + expect(content).not.toContain("v8_passThroughRequests"); + expect(content).not.toContain("v8_trailingSlashAwareDataRequests"); + }); + + it("uses unstable_viteEnvironmentApi for React Router < 7.10.0", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + + const framework = createFramework("7.9.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("react-router.config.ts"), "utf-8"); + expect(content).toContain("unstable_viteEnvironmentApi: true"); + expect(content).not.toContain("v8_viteEnvironmentApi"); + }); + + it("preserves existing future flags when adding viteEnvironmentApi", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-middleware-true.ts") + ); + + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("react-router.config.ts"), "utf-8"); + // Existing flag preserved + expect(content).toContain("v8_middleware: true"); + // New flag added + expect(content).toContain("v8_viteEnvironmentApi: true"); + }); + }); + + describe("React Router >= 8.0.0", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + }); + + it("does not add future flags to react-router.config.ts", async ({ + expect, + }) => { + const original = readFileSync(resolve("react-router.config.ts"), "utf-8"); + + const framework = createFramework("8.0.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("react-router.config.ts"), "utf-8"); + expect(content).not.toContain("v8_viteEnvironmentApi"); + expect(content).not.toContain("unstable_viteEnvironmentApi"); + expect(content).toBe(original); + }); + + it("does not add future flags for React Router 8.x", async ({ expect }) => { + const original = readFileSync(resolve("react-router.config.ts"), "utf-8"); + + const framework = createFramework("8.2.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("react-router.config.ts"), "utf-8"); + expect(content).not.toContain("v8_viteEnvironmentApi"); + expect(content).not.toContain("unstable_viteEnvironmentApi"); + expect(content).toBe(original); + }); + + it("uses middleware pattern by default for workers/app.ts", async ({ + expect, + }) => { + const framework = createFramework("8.0.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("workers/app.ts"), "utf-8"); + expect(content).not.toContain("AppLoadContext"); + expect(content).not.toContain("declare module"); + expect(content).toContain("async fetch(request)"); + expect(content).not.toContain("env, ctx"); + expect(content).toContain("requestHandler(request)"); + }); + + it("uses middleware pattern by default for entry.server.tsx", async ({ + expect, + }) => { + const framework = createFramework("8.0.0"); + await framework.configure(getBaseOptions()); + + const content = readFileSync(resolve("app/entry.server.tsx"), "utf-8"); + expect(content).not.toContain("AppLoadContext"); + expect(content).not.toContain("_loadContext"); + expect(content).toContain( + 'import type { EntryContext } from "react-router"' + ); + }); + }); + + describe("hasV8MiddlewareFlag with version >= 8.0.0", () => { + it("returns true without config file when version >= 8.0.0", ({ + expect, + }) => { + expect(hasV8MiddlewareFlag(process.cwd(), "8.0.0")).toBe(true); + }); + + it("returns true regardless of config content when version >= 8.0.0", async ({ + expect, + }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd(), "8.0.0")).toBe(true); + }); + + it("still parses config for versions < 8.0.0", async ({ expect }) => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + expect(hasV8MiddlewareFlag(process.cwd(), "7.16.0")).toBe(false); + }); + }); + + describe("dry run", () => { + it("does not create files in dry run mode", async ({ expect }) => { + const framework = createFramework("7.16.0"); + const result = await framework.configure({ + ...getBaseOptions(), + dryRun: true, + }); + + expect(result.wranglerConfig).toEqual({ + main: "./workers/app.ts", + }); + expect(existsSync(resolve("workers/app.ts"))).toBe(false); + expect(existsSync(resolve("app/entry.server.tsx"))).toBe(false); + }); + }); + + describe("wrangler config", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + }); + + it("returns wrangler config with main pointing to workers/app.ts", async ({ + expect, + }) => { + const framework = createFramework("7.16.0"); + const result = await framework.configure(getBaseOptions()); + + expect(result.wranglerConfig).toEqual({ + main: "./workers/app.ts", + }); + }); + }); + + describe("package installation", () => { + beforeEach(async () => { + await writeFile( + resolve("react-router.config.ts"), + fixture("config-no-future.ts") + ); + }); + + it("installs isbot as a dev dependency", async ({ expect }) => { + const framework = createFramework("7.16.0"); + await framework.configure(getBaseOptions()); + + expect(cliPackages.installPackages).toHaveBeenCalledWith( + NpmPackageManager.type, + ["isbot"], + expect.objectContaining({ dev: true }) + ); + }); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/utils/vite-plugin.test.ts b/packages/autoconfig/tests/frameworks/utils/vite-plugin.test.ts new file mode 100644 index 0000000..225bbf6 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/utils/vite-plugin.test.ts @@ -0,0 +1,288 @@ +import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; +import { beforeEach, describe, it, vi } from "vitest"; +import { getInstalledPackageVersion } from "../../../src/frameworks/utils/packages"; +import { installCloudflareVitePlugin } from "../../../src/frameworks/utils/vite-plugin"; +import type { MockInstance } from "vitest"; + +vi.mock("../../../src/frameworks/utils/packages", () => ({ + getInstalledPackageVersion: vi.fn(), +})); + +describe("installCloudflareVitePlugin", () => { + let installSpy: MockInstance; + + beforeEach(() => { + installSpy = vi + .spyOn(cliPackages, "installPackages") + .mockImplementation(async () => {}); + }); + + describe("when Vite is not installed/detected", () => { + beforeEach(() => { + vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); + }); + + it("installs only @cloudflare/vite-plugin", async ({ expect }) => { + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledWith( + "npm", + ["@cloudflare/vite-plugin"], + expect.objectContaining({ dev: true }) + ); + }); + + it("does not attempt to upgrade Vite", async ({ expect }) => { + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).not.toHaveBeenCalledWith( + expect.anything(), + ["vite@^6.1.0"], + expect.anything() + ); + }); + }); + + describe("when Vite version is in [6.0.0, 6.1.0) range", () => { + it("upgrades Vite before installing the plugin for version 6.0.0", async ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("6.0.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(2); + expect(installSpy).toHaveBeenNthCalledWith( + 1, + "npm", + ["vite@^6.1.0"], + expect.objectContaining({ dev: true }) + ); + expect(installSpy).toHaveBeenNthCalledWith( + 2, + "npm", + ["@cloudflare/vite-plugin"], + expect.objectContaining({ dev: true }) + ); + }); + + it("upgrades Vite before installing the plugin for version 6.0.5", async ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("6.0.5"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(2); + expect(installSpy).toHaveBeenNthCalledWith( + 1, + "npm", + ["vite@^6.1.0"], + expect.objectContaining({ dev: true }) + ); + expect(installSpy).toHaveBeenNthCalledWith( + 2, + "npm", + ["@cloudflare/vite-plugin"], + expect.objectContaining({ dev: true }) + ); + }); + }); + + describe("when Vite version is >= 6.1.0", () => { + it("does not upgrade Vite for version 6.1.0", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("6.1.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledWith( + "npm", + ["@cloudflare/vite-plugin"], + expect.objectContaining({ dev: true }) + ); + }); + + it("does not upgrade Vite for version 6.2.0", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("6.2.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).not.toHaveBeenCalledWith( + expect.anything(), + ["vite@^6.1.0"], + expect.anything() + ); + }); + + it("does not upgrade Vite for version 7.0.0", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("7.0.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).not.toHaveBeenCalledWith( + expect.anything(), + ["vite@^6.1.0"], + expect.anything() + ); + }); + }); + + describe("when Vite version is < 6.0.0", () => { + it("does not upgrade Vite for version 5.4.0", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("5.4.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).not.toHaveBeenCalledWith( + expect.anything(), + ["vite@^6.1.0"], + expect.anything() + ); + }); + + it("does not upgrade Vite for version 4.0.0", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.0.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledWith( + "npm", + ["@cloudflare/vite-plugin"], + expect.objectContaining({ dev: true }) + ); + }); + }); + + describe("parameter forwarding", () => { + beforeEach(() => { + vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); + }); + + it("forwards isWorkspaceRoot to installPackages", async ({ expect }) => { + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: true, + }); + + expect(installSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ isWorkspaceRoot: true }) + ); + }); + + it("forwards isWorkspaceRoot when upgrading Vite", async ({ expect }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("6.0.0"); + + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/test/project", + isWorkspaceRoot: true, + }); + + // Both the Vite upgrade and plugin install should have isWorkspaceRoot: true + expect(installSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + ["vite@^6.1.0"], + expect.objectContaining({ isWorkspaceRoot: true }) + ); + expect(installSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + ["@cloudflare/vite-plugin"], + expect.objectContaining({ isWorkspaceRoot: true }) + ); + }); + + it("forwards packageManager to installPackages", async ({ expect }) => { + await installCloudflareVitePlugin({ + packageManager: "pnpm", + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledWith( + "pnpm", + expect.anything(), + expect.anything() + ); + }); + + it("forwards different package managers correctly", async ({ expect }) => { + for (const pm of ["npm", "yarn", "pnpm", "bun"] as const) { + installSpy.mockClear(); + + await installCloudflareVitePlugin({ + packageManager: pm, + projectPath: "/test/project", + isWorkspaceRoot: false, + }); + + expect(installSpy).toHaveBeenCalledWith( + pm, + expect.anything(), + expect.anything() + ); + } + }); + + it("passes projectPath to getInstalledPackageVersion", async ({ + expect, + }) => { + await installCloudflareVitePlugin({ + packageManager: "npm", + projectPath: "/custom/path", + isWorkspaceRoot: false, + }); + + expect(getInstalledPackageVersion).toHaveBeenCalledWith( + "vite", + "/custom/path" + ); + }); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/validate-framework-version.test.ts b/packages/autoconfig/tests/frameworks/validate-framework-version.test.ts new file mode 100644 index 0000000..440aec9 --- /dev/null +++ b/packages/autoconfig/tests/frameworks/validate-framework-version.test.ts @@ -0,0 +1,149 @@ +import { mockConsoleMethods } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it, vi } from "vitest"; +import { AutoConfigFrameworkConfigurationError } from "../../src/errors"; +import { Framework } from "../../src/frameworks/framework-class"; +import { getInstalledPackageVersion } from "../../src/frameworks/utils/packages"; +import { createMockContext } from "../helpers/mock-context"; +import type { AutoConfigFrameworkPackageInfo } from "../../src/frameworks"; +import type { + ConfigurationOptions, + ConfigurationResults, +} from "../../src/frameworks/framework-class"; + +vi.mock("../../src/frameworks/utils/packages"); + +/** Minimal concrete subclass so we can instantiate the abstract Framework */ +class TestFramework extends Framework { + configure(_options: ConfigurationOptions): ConfigurationResults { + return { wranglerConfig: null }; + } +} + +const PACKAGE_INFO: AutoConfigFrameworkPackageInfo = { + name: "some-pkg", + minimumVersion: "2.0.0", + maximumKnownMajorVersion: "4", +}; + +describe("Framework.validateFrameworkVersion()", () => { + const std = mockConsoleMethods(); + const context = createMockContext(); + + it("throws an AssertionError when the package version cannot be determined", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue(undefined); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).toThrow("Unable to detect the version of the `some-pkg` package"); + }); + + it("throws AutoConfigFrameworkConfigurationError when installed version is below minimum", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("1.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).toThrow(AutoConfigFrameworkConfigurationError); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).toThrowErrorMatchingInlineSnapshot( + `[Error: The version of Test used in the project ("1.0.0") cannot be automatically configured. Please update the Test version to at least "2.0.0" and try again.]` + ); + }); + + it("does not throw and sets frameworkVersion when installed version equals minimumVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("2.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("2.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and sets frameworkVersion when installed version is within known range", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("3.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("3.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and does not warn when installed version equals maximumKnownMajorVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.0.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw and does not warn when installed version is a minor/patch update within the known major", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.5.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.5.0"); + expect(std.warn).toBe(""); + }); + + it("does not throw nor warn when the installed version is an update within the known major", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("4.3.2"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("4.3.2"); + expect(std.warn).toBe(""); + }); + + it("does not throw but warns when installed version exceeds maximumKnownMajorVersion", ({ + expect, + }) => { + vi.mocked(getInstalledPackageVersion).mockReturnValue("5.0.0"); + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => + framework.validateFrameworkVersion("/project", PACKAGE_INFO, context) + ).not.toThrow(); + expect(framework.frameworkVersion).toBe("5.0.0"); + expect(std.warn).toContain('"5.0.0"'); + expect(std.warn).toContain("Test"); + expect(std.warn).toContain("is not officially supported"); + }); + + it("throws an AssertionError when frameworkVersion getter is accessed before validateFrameworkVersion is called", ({ + expect, + }) => { + const framework = new TestFramework({ id: "test", name: "Test" }); + + expect(() => framework.frameworkVersion).toThrow( + 'The version for "Test" is unexpectedly missing' + ); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/vike.test.ts b/packages/autoconfig/tests/frameworks/vike.test.ts new file mode 100644 index 0000000..2cdaf3f --- /dev/null +++ b/packages/autoconfig/tests/frameworks/vike.test.ts @@ -0,0 +1,326 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, it, vi } from "vitest"; +import { Vike } from "../../src/frameworks/vike"; +import { createMockContext } from "../helpers/mock-context"; + +vi.mock("../../src/frameworks/utils/packages", () => ({ + isPackageInstalled: () => false, +})); + +vi.mock("../../src/frameworks/utils/vite-plugin", () => ({ + installCloudflareVitePlugin: async () => {}, +})); + +const context = createMockContext(); + +function getBaseOptions() { + return { + projectPath: process.cwd(), + workerName: "my-vike-app", + outputDir: "", + dryRun: false, + packageManager: NpmPackageManager, + isWorkspaceRoot: false, + context, + }; +} + +describe("Vike framework configure()", () => { + runInTempDir(); + + beforeEach(() => { + vi.spyOn(cliPackages, "installPackages").mockImplementation(async () => {}); + }); + + describe("config file transformation", () => { + it("handles the old format: `export default { ... } satisfies Config`", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import type { Config } from "vike/types"; +import vikeReact from "vike-react/config"; + +// Default config (can be overridden by pages) +// https://vike.dev/config + +export default { + // https://vike.dev/head-tags + title: "My Vike App", + description: "Demo showcasing Vike", + + extends: [vikeReact], +} satisfies Config; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + + it("handles the new format: `const config: Config = { ... }; export default config;`", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import type { Config } from "vike/types"; +import vikeReact from "vike-react/config"; + +// Default config (can be overridden by pages) +// https://vike.dev/config + +const config: Config = { + // https://vike.dev/head-tags + title: "My Vike App", + description: "Demo showcasing Vike", + + extends: [vikeReact], +}; + +export default config; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + + it("handles plain format: `export default { ... }`", async ({ expect }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import vikeReact from "vike-react/config"; + +export default { + title: "My Vike App", + extends: [vikeReact], +}; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + + it("handles `export default { ... } as Config` format", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import type { Config } from "vike/types"; +import vikeReact from "vike-react/config"; + +export default { + title: "My Vike App", + extends: [vikeReact], +} as Config; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + + it("creates extends array if not present", async ({ expect }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import vikeReact from "vike-react/config"; + +export default { + title: "My Vike App", +}; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikePhoton]"); + }); + + it("creates extends array on variable-referenced config if not present", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import type { Config } from "vike/types"; + +const config: Config = { + title: "My Vike App", +}; + +export default config; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikePhoton]"); + }); + + it("does not duplicate import or extends entry on repeated calls", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import vikeReact from "vike-react/config"; +import vikePhoton from "vike-photon/config"; + +export default { + title: "My Vike App", + extends: [vikeReact, vikePhoton], +}; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + + const importCount = ( + result.match(/import vikePhoton from "vike-photon\/config"/g) || [] + ).length; + expect(importCount).toBe(1); + + // Verify the extends array wasn't duplicated either + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + + it("does not duplicate extends entry when vike-photon is imported under a different name", async ({ + expect, + }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import vikeReact from "vike-react/config"; +import photon from "vike-photon/config"; + +export default { + title: "My Vike App", + extends: [vikeReact, photon], +}; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + + // Should not add a second import + const importCount = (result.match(/from "vike-photon\/config"/g) || []) + .length; + expect(importCount).toBe(1); + + // Should not duplicate the extends entry under any name + expect(result).toContain("extends: [vikeReact, photon]"); + expect(result).not.toContain("vikePhoton"); + }); + + it("uses .js config file as fallback", async ({ expect }) => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.js"), + `import vikeReact from "vike-react/config"; + +export default { + title: "My Vike App", + extends: [vikeReact], +}; +` + ); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure(getBaseOptions()); + + const result = await readFile(resolve("pages/+config.js"), "utf8"); + expect(result).toContain('import vikePhoton from "vike-photon/config";'); + expect(result).toContain("extends: [vikeReact, vikePhoton]"); + }); + }); + + describe("configure() return value", () => { + beforeEach(async () => { + await mkdir(resolve("pages"), { recursive: true }); + await writeFile( + resolve("pages/+config.ts"), + `import vikeReact from "vike-react/config"; + +export default { + extends: [vikeReact], +}; +` + ); + }); + + it("returns correct wranglerConfig", async ({ expect }) => { + const framework = new Vike({ id: "vike", name: "Vike" }); + const result = await framework.configure(getBaseOptions()); + + expect(result.wranglerConfig).toEqual({ + main: "virtual:photon:cloudflare:server-entry", + }); + }); + + it("returns correct packageJsonScriptsOverrides", async ({ expect }) => { + const framework = new Vike({ id: "vike", name: "Vike" }); + const result = await framework.configure(getBaseOptions()); + + expect(result.packageJsonScriptsOverrides).toEqual({ + preview: "vike build && vike preview", + deploy: "vike build && wrangler deploy", + }); + }); + }); + + describe("dryRun mode", () => { + it("does not modify config file in dryRun mode", async ({ expect }) => { + await mkdir(resolve("pages"), { recursive: true }); + const originalContent = `import vikeReact from "vike-react/config"; + +export default { + extends: [vikeReact], +}; +`; + await writeFile(resolve("pages/+config.ts"), originalContent); + + const framework = new Vike({ id: "vike", name: "Vike" }); + await framework.configure({ ...getBaseOptions(), dryRun: true }); + + const result = await readFile(resolve("pages/+config.ts"), "utf8"); + expect(result).toBe(originalContent); + }); + }); +}); diff --git a/packages/autoconfig/tests/frameworks/vite.test.ts b/packages/autoconfig/tests/frameworks/vite.test.ts new file mode 100644 index 0000000..68757ca --- /dev/null +++ b/packages/autoconfig/tests/frameworks/vite.test.ts @@ -0,0 +1,111 @@ +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import * as cliPackages from "@cloudflare/cli-shared-helpers/packages"; +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, it, vi } from "vitest"; +import { Vite } from "../../src/frameworks/vite"; +import { createMockContext } from "../helpers/mock-context"; + +const context = createMockContext(); + +const BASE_OPTIONS = { + projectPath: ".", + workerName: "my-vite-app", + outputDir: "dist", + dryRun: false, + packageManager: NpmPackageManager, + isWorkspaceRoot: false, + context, +}; + +describe("Vite framework", () => { + runInTempDir(); + + beforeEach(() => { + vi.spyOn(cliPackages, "installPackages").mockImplementation(async () => {}); + }); + + describe("isConfigured()", () => { + it("returns false when no vite config file exists", ({ expect }) => { + const framework = new Vite({ id: "vite", name: "Vite" }); + expect(framework.isConfigured(".")).toBe(false); + }); + }); + + describe("configure()", () => { + it("creates a vite config with the cloudflare plugin when no config file exists", async ({ + expect, + }) => { + const framework = new Vite({ id: "vite", name: "Vite" }); + const result = await framework.configure(BASE_OPTIONS); + + expect(existsSync("vite.config.js")).toBe(true); + const content = readFileSync("vite.config.js", "utf-8"); + expect(content).toContain( + 'import { cloudflare } from "@cloudflare/vite-plugin"' + ); + expect(content).toContain("plugins: [cloudflare()]"); + + expect(result.wranglerConfig).toEqual({ + assets: { + not_found_handling: "single-page-application", + }, + }); + }); + + it("uses .ts extension when the project has a tsconfig.json", async ({ + expect, + }) => { + await writeFile("tsconfig.json", "{}"); + + const framework = new Vite({ id: "vite", name: "Vite" }); + await framework.configure(BASE_OPTIONS); + + expect(existsSync("vite.config.ts")).toBe(true); + expect(existsSync("vite.config.js")).toBe(false); + }); + + it("transforms existing vite config instead of creating a new one", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()] +}); +` + ); + + const framework = new Vite({ id: "vite", name: "Vite" }); + await framework.configure(BASE_OPTIONS); + + const content = readFileSync("vite.config.ts", "utf-8"); + expect(content).toContain("cloudflare()"); + // Existing plugins should be preserved + expect(content).toContain("react()"); + }); + + it("does not create or modify files in dry-run mode", async ({ + expect, + }) => { + const framework = new Vite({ id: "vite", name: "Vite" }); + const result = await framework.configure({ + ...BASE_OPTIONS, + dryRun: true, + }); + + expect(existsSync("vite.config.ts")).toBe(false); + expect(existsSync("vite.config.js")).toBe(false); + expect(result.wranglerConfig).toEqual({ + assets: { + not_found_handling: "single-page-application", + }, + }); + }); + }); +}); diff --git a/packages/autoconfig/tests/get-installed-package-version.test.ts b/packages/autoconfig/tests/get-installed-package-version.test.ts new file mode 100644 index 0000000..533374d --- /dev/null +++ b/packages/autoconfig/tests/get-installed-package-version.test.ts @@ -0,0 +1,81 @@ +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { describe, test } from "vitest"; +import { getInstalledPackageVersion } from "../src/frameworks/utils/packages"; + +describe("getInstalledPackageVersion()", () => { + runInTempDir(); + test("happy path", async ({ expect }) => { + await seed({ + "node_modules/react-router/package.json": JSON.stringify({ + name: "react-router", + version: "1.2.3", + main: "index.js", + }), + "node_modules/react-router/index.js": "console.log(1)", + }); + expect(getInstalledPackageVersion("react-router", process.cwd())).toBe( + "1.2.3" + ); + }); + + test("no node_modules", async ({ expect }) => { + expect( + getInstalledPackageVersion("react-router", process.cwd()) + ).toBeUndefined(); + }); + + test("aliased package returns the bundled version of the requested package", async ({ + expect, + }) => { + // vite+ installs `@voidzero-dev/vite-plus-core` under the `vite` alias, so + // the resolved package.json has a different `name` and its own `version`. + await seed({ + "node_modules/vite/package.json": JSON.stringify({ + name: "@voidzero-dev/vite-plus-core", + version: "0.2.2", + bundledVersions: { + vite: "8.1.2", + rolldown: "1.0.0", + tsdown: "0.15.0", + }, + main: "index.js", + }), + "node_modules/vite/index.js": "console.log(1)", + }); + expect(getInstalledPackageVersion("vite", process.cwd())).toBe("8.1.2"); + }); + + test("aliased package without a matching bundled version falls back to its own version", async ({ + expect, + }) => { + await seed({ + "node_modules/vite/package.json": JSON.stringify({ + name: "@voidzero-dev/vite-plus-core", + version: "0.2.2", + bundledVersions: { + rolldown: "1.0.0", + }, + main: "index.js", + }), + "node_modules/vite/index.js": "console.log(1)", + }); + expect(getInstalledPackageVersion("vite", process.cwd())).toBe("0.2.2"); + }); + + test("package whose name matches ignores bundledVersions and returns its own version", async ({ + expect, + }) => { + await seed({ + "node_modules/vite/package.json": JSON.stringify({ + name: "vite", + version: "6.3.0", + bundledVersions: { + vite: "8.1.2", + }, + main: "index.js", + }), + "node_modules/vite/index.js": "console.log(1)", + }); + expect(getInstalledPackageVersion("vite", process.cwd())).toBe("6.3.0"); + }); +}); diff --git a/packages/autoconfig/tests/helpers/mock-context.ts b/packages/autoconfig/tests/helpers/mock-context.ts new file mode 100644 index 0000000..98d8d3f --- /dev/null +++ b/packages/autoconfig/tests/helpers/mock-context.ts @@ -0,0 +1,34 @@ +import { vi } from "vitest"; +import type { AutoConfigContext } from "../../src/context"; + +/** + * Creates a mock `AutoConfigContext` suitable for testing. + * All dialog methods default to returning sensible values. + * The logger delegates to `console` so that `mockConsoleMethods()` captures + * the output and tests can assert on `std.out`, `std.warn`, etc. + * + * @param overrides - Partial overrides to customize the context. + * @returns A fully mocked `AutoConfigContext`. + */ +export function createMockContext( + overrides?: Partial +): AutoConfigContext { + return { + logger: { + log: vi.fn((...args: unknown[]) => console.log(...args)), + info: vi.fn((...args: unknown[]) => console.info(...args)), + warn: vi.fn((...args: unknown[]) => console.warn(...args)), + debug: vi.fn((...args: unknown[]) => console.debug(...args)), + error: vi.fn((...args: unknown[]) => console.error(...args)), + }, + dialogs: { + confirm: vi.fn().mockResolvedValue(true), + prompt: vi.fn().mockResolvedValue(""), + select: vi.fn().mockResolvedValue(""), + }, + runCommand: vi.fn(), + isNonInteractiveOrCI: () => false, + getCacheFolder: () => undefined, + ...overrides, + }; +} diff --git a/packages/autoconfig/tests/run-summary.test.ts b/packages/autoconfig/tests/run-summary.test.ts new file mode 100644 index 0000000..8df4ca3 --- /dev/null +++ b/packages/autoconfig/tests/run-summary.test.ts @@ -0,0 +1,250 @@ +import { NpmPackageManager } from "@cloudflare/workers-utils"; +import { mockConsoleMethods } from "@cloudflare/workers-utils/test-helpers"; +import { dedent } from "ts-dedent"; +import { describe, test } from "vitest"; +import { Astro } from "../src/frameworks/astro"; +import { Static } from "../src/frameworks/static"; +import { buildOperationsSummary } from "../src/run"; +import { createMockContext } from "./helpers/mock-context"; +import type { RawConfig } from "@cloudflare/workers-utils"; + +const testRawConfig: RawConfig = { + $schema: "node_modules/wrangler/config-schema.json", + name: "worker-name", + compatibility_date: "2025-01-01", + observability: { + enabled: true, + }, +}; + +describe("autoconfig run - buildOperationsSummary()", () => { + const std = mockConsoleMethods(); + const context = createMockContext(); + + describe("interactive mode", () => { + test("presents a summary for a simple project where only a wrangler.jsonc file needs to be created", async ({ + expect, + }) => { + const summary = await buildOperationsSummary( + { + workerName: "worker-name", + projectPath: "", + configured: false, + outputDir: "public", + framework: new Static({ id: "static", name: "Static" }), + packageManager: NpmPackageManager, + }, + testRawConfig, + { + build: "npm run build", + deploy: "npx wrangler deploy", + version: "npx wrangler versions upload", + }, + context + ); + + expect(std.out).toMatchInlineSnapshot(` + " + 📄 Create wrangler.jsonc: + { + "$schema": "node_modules/wrangler/config-schema.json", + "name": "worker-name", + "compatibility_date": "2025-01-01", + "observability": { + "enabled": true + } + } + " + `); + + expect(summary).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "deployCommand": "npx wrangler deploy", + "frameworkId": "static", + "outputDir": "public", + "scripts": {}, + "versionCommand": "npx wrangler versions upload", + "wranglerConfig": { + "$schema": "node_modules/wrangler/config-schema.json", + "compatibility_date": "2025-01-01", + "name": "worker-name", + "observability": { + "enabled": true, + }, + }, + "wranglerInstall": false, + } + `); + }); + + test("shows that wrangler will be added as a devDependency when not already installed as such", async ({ + expect, + }) => { + const summary = await buildOperationsSummary( + { + workerName: "worker-name", + projectPath: "", + packageJson: { + name: "my-project", + devDependencies: {}, + }, + configured: false, + outputDir: "dist", + framework: new Static({ id: "static", name: "Static" }), + packageManager: NpmPackageManager, + }, + testRawConfig, + { + build: "npm run build", + deploy: "npx wrangler deploy", + version: "npx wrangler versions upload", + }, + context + ); + + expect(std.out).toContain( + dedent` + 📦 Install packages: + - wrangler (devDependency) + ` + ); + + expect(summary).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "deployCommand": "npx wrangler deploy", + "frameworkId": "static", + "outputDir": "dist", + "scripts": { + "deploy": "wrangler deploy", + "preview": "wrangler dev", + }, + "versionCommand": "npx wrangler versions upload", + "wranglerConfig": { + "$schema": "node_modules/wrangler/config-schema.json", + "compatibility_date": "2025-01-01", + "name": "worker-name", + "observability": { + "enabled": true, + }, + }, + "wranglerInstall": true, + } + `); + }); + + test("when a package.json is present wrangler@latest will be unconditionally installed (even if already present)", async ({ + expect, + }) => { + const summary = await buildOperationsSummary( + { + workerName: "worker-name", + projectPath: "", + packageJson: { + name: "my-project", + devDependencies: { + wrangler: "^4.0.0", + }, + }, + configured: false, + outputDir: "out", + framework: new Static({ id: "static", name: "Static" }), + packageManager: NpmPackageManager, + }, + testRawConfig, + { + build: "npm run build", + deploy: "npx wrangler deploy", + version: "npx wrangler versions upload", + }, + context + ); + + expect(std.out).toContain( + dedent` + 📦 Install packages: + - wrangler (devDependency) + ` + ); + + expect(summary).toMatchInlineSnapshot(` + { + "buildCommand": "npm run build", + "deployCommand": "npx wrangler deploy", + "frameworkId": "static", + "outputDir": "out", + "scripts": { + "deploy": "wrangler deploy", + "preview": "wrangler dev", + }, + "versionCommand": "npx wrangler versions upload", + "wranglerConfig": { + "$schema": "node_modules/wrangler/config-schema.json", + "compatibility_date": "2025-01-01", + "name": "worker-name", + "observability": { + "enabled": true, + }, + }, + "wranglerInstall": true, + } + `); + }); + + test("shows that when needed a framework specific configuration will be run", async ({ + expect, + }) => { + const summary = await buildOperationsSummary( + { + workerName: "worker-name", + projectPath: "", + framework: new Astro({ id: "astro", name: "Astro" }), + configured: false, + outputDir: "dist", + packageManager: NpmPackageManager, + }, + testRawConfig, + { + build: "npm run build", + deploy: "npx wrangler deploy", + }, + context + ); + + expect(std.out).toContain( + '🛠️ Configuring project for Astro with "astro add cloudflare"' + ); + + expect(summary.frameworkConfiguration).toBe( + 'Configuring project for Astro with "astro add cloudflare"' + ); + + expect(summary.frameworkId).toBe("astro"); + }); + + test("doesn't show the framework specific configuration step for the Static framework", async ({ + expect, + }) => { + const summary = await buildOperationsSummary( + { + workerName: "worker-name", + projectPath: "", + framework: new Static({ id: "static", name: "Static" }), + configured: false, + outputDir: "public", + packageManager: NpmPackageManager, + }, + testRawConfig, + { + build: "npm run build", + deploy: "npx wrangler deploy", + }, + context + ); + + expect(std.out).not.toContain("🛠️ Configuring project for"); + expect(summary.frameworkConfiguration).toBeUndefined(); + }); + }); +}); diff --git a/packages/autoconfig/tests/tsconfig.json b/packages/autoconfig/tests/tsconfig.json new file mode 100644 index 0000000..ca97bed --- /dev/null +++ b/packages/autoconfig/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "preserve", + "types": ["node"] + }, + "include": ["../*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"] +} diff --git a/packages/autoconfig/tests/vite-config.test.ts b/packages/autoconfig/tests/vite-config.test.ts new file mode 100644 index 0000000..84ff3b9 --- /dev/null +++ b/packages/autoconfig/tests/vite-config.test.ts @@ -0,0 +1,304 @@ +import { readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { + mockConsoleMethods, + runInTempDir, +} from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { + checkIfViteConfigUsesCloudflarePlugin, + transformViteConfig, +} from "../src/frameworks/utils/vite-config"; +import { createMockContext } from "./helpers/mock-context"; + +describe("vite-config utils", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const context = createMockContext(); + + describe("checkIfViteConfigUsesCloudflarePlugin", () => { + it("should detect cloudflare plugin in function-based defineConfig", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; +import { cloudflare } from '@cloudflare/vite-plugin'; + +export default defineConfig(() => ({ + plugins: [cloudflare()] +})); +` + ); + + const result = checkIfViteConfigUsesCloudflarePlugin(".", context); + expect(result).toBe(true); + }); + + it("should return false for function-based defineConfig without cloudflare plugin", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + plugins: [] +})); +` + ); + + const result = checkIfViteConfigUsesCloudflarePlugin(".", context); + expect(result).toBe(false); + }); + + it("should handle vite config without plugins array", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { port: 3000 } +}); +` + ); + + const result = checkIfViteConfigUsesCloudflarePlugin(".", context); + expect(result).toBe(false); + expect(std.debug).toContain( + "Vite config does not have a valid plugins array" + ); + }); + + it("should detect cloudflare plugin correctly", async ({ expect }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; +import { cloudflare } from '@cloudflare/vite-plugin'; + +export default defineConfig({ + plugins: [cloudflare()] +}); +` + ); + + const result = checkIfViteConfigUsesCloudflarePlugin(".", context); + expect(result).toBe(true); + }); + }); + + describe("transformViteConfig", () => { + it("should successfully transform function-based defineConfig with arrow expression body", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + plugins: [] +})); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).toContain( + 'import { cloudflare } from "@cloudflare/vite-plugin"' + ); + expect(result).toContain("cloudflare()"); + }); + + it("should successfully transform function-based defineConfig with destructured params", async ({ + expect, + }) => { + // This is the exact pattern from the React Router node-postgres template + await writeFile( + "vite.config.ts", + ` +import { reactRouter } from "@react-router/dev/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ isSsrBuild }) => ({ + build: { + rollupOptions: isSsrBuild + ? { + input: "./server/app.ts", + } + : undefined, + }, + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +})); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).toContain( + 'import { cloudflare } from "@cloudflare/vite-plugin"' + ); + expect(result).toContain("cloudflare()"); + // The existing plugins should be preserved + expect(result).toContain("tailwindcss()"); + expect(result).toContain("reactRouter()"); + expect(result).toContain("tsconfigPaths()"); + // The function structure should be preserved + expect(result).toContain("isSsrBuild"); + }); + + it("should successfully transform function-based defineConfig with block body", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => { + return { + plugins: [] + }; +}); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).toContain( + 'import { cloudflare } from "@cloudflare/vite-plugin"' + ); + expect(result).toContain("cloudflare()"); + }); + + it("should successfully transform function expression defineConfig", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(function() { + return { + plugins: [] + }; +}); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).toContain( + 'import { cloudflare } from "@cloudflare/vite-plugin"' + ); + expect(result).toContain("cloudflare()"); + }); + + it("should pass viteEnvironmentName option with function-based config", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + plugins: [] +})); +` + ); + + expect(() => + transformViteConfig(".", { viteEnvironmentName: "ssr" }) + ).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).toContain("viteEnvironment"); + expect(result).toContain('"ssr"'); + }); + + it("should remove incompatible plugins from function-based config", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + plugins: [nitro(), someOther()] +})); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + const result = readFileSync("vite.config.ts", "utf-8"); + expect(result).not.toContain("nitro()"); + expect(result).toContain("someOther()"); + expect(result).toContain("cloudflare()"); + }); + + it("should throw UserError when plugins array is missing", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { port: 3000 } +}); +` + ); + + expect(() => transformViteConfig(".")).toThrow( + /Cannot modify Vite config: could not find a valid plugins array/ + ); + }); + + it("should throw UserError when plugins array is missing in function-based config", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + server: { port: 3000 } +})); +` + ); + + expect(() => transformViteConfig(".")).toThrow( + /Cannot modify Vite config: could not find a valid plugins array/ + ); + }); + + it("should successfully transform valid vite config", async ({ + expect, + }) => { + await writeFile( + "vite.config.ts", + ` +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [] +}); +` + ); + + expect(() => transformViteConfig(".")).not.toThrow(); + }); + }); +}); diff --git a/packages/autoconfig/tsconfig.json b/packages/autoconfig/tsconfig.json new file mode 100644 index 0000000..533933f --- /dev/null +++ b/packages/autoconfig/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "types": ["node"], + "tsBuildInfoFile": ".tsbuildinfo" + }, + "include": ["**/*.ts", "**/*.js"], + "exclude": ["dist", "node_modules", "**/__tests__", "**/*.test.ts", "tests"] +} diff --git a/packages/autoconfig/tsup.config.ts b/packages/autoconfig/tsup.config.ts new file mode 100644 index 0000000..9a4c974 --- /dev/null +++ b/packages/autoconfig/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsup"; +import { EXTERNAL_DEPENDENCIES } from "./scripts/deps"; + +export default defineConfig(() => [ + { + treeshake: true, + keepNames: true, + entry: ["src/index.ts"], + platform: "node", + format: "esm", + dts: true, + outDir: "dist", + tsconfig: "tsconfig.json", + metafile: true, + sourcemap: process.env.SOURCEMAPS !== "false", + define: { + "process.env.NODE_ENV": `'${"production"}'`, + }, + external: EXTERNAL_DEPENDENCIES, + }, +]); diff --git a/packages/autoconfig/turbo.json b/packages/autoconfig/turbo.json new file mode 100644 index 0000000..691f611 --- /dev/null +++ b/packages/autoconfig/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!**/tests/**"], + "outputs": ["dist/**"], + "env": ["SOURCEMAPS"], + "passThroughEnv": ["PWD"] + }, + "test:ci": { + "dependsOn": ["build"], + "env": ["LC_ALL", "TZ"] + } + } +} diff --git a/packages/autoconfig/vitest.config.mts b/packages/autoconfig/vitest.config.mts new file mode 100644 index 0000000..407d414 --- /dev/null +++ b/packages/autoconfig/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 15_000, + pool: "forks", + include: ["**/tests/**/*.test.ts"], + reporters: ["default"], + unstubEnvs: true, + mockReset: true, + }, +}); diff --git a/packages/chrome-devtools-patches/CHANGELOG.md b/packages/chrome-devtools-patches/CHANGELOG.md new file mode 100644 index 0000000..2dfddb4 --- /dev/null +++ b/packages/chrome-devtools-patches/CHANGELOG.md @@ -0,0 +1,50 @@ +# @cloudflare/chrome-devtools-patches + +## 0.1.5 + +### Patch Changes + +- [#12928](https://github.com/cloudflare/workers-sdk/pull/12928) [`81ee98e`](https://github.com/cloudflare/workers-sdk/commit/81ee98e6a0c6be879757289ef6e34e1559d6ee2a) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Migrate chrome-devtools-patches deployment from Cloudflare Pages to Workers + Assets + + The DevTools frontend is now deployed as a Cloudflare Workers + Assets project instead of a Cloudflare Pages project. This uses `wrangler deploy` for production deployments and `wrangler versions upload` for PR preview deployments. + + The inspector proxy origin allowlists in both wrangler and miniflare have been updated to accept connections from the new `workers.dev` domain patterns, while retaining the legacy `pages.dev` patterns for backward compatibility. + +## 0.1.4 + +### Patch Changes + +- [#9649](https://github.com/cloudflare/workers-sdk/pull/9649) [`ec9b417`](https://github.com/cloudflare/workers-sdk/commit/ec9b417f8ed711e7b5044410e83d781f123a6a62) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - patch release to trigger a test release + +## 0.1.3 + +### Patch Changes + +- [#9551](https://github.com/cloudflare/workers-sdk/pull/9551) [`b0b59e0`](https://github.com/cloudflare/workers-sdk/commit/b0b59e071393e13e3770fd59fb0cb26136e88272) Thanks [@penalosa](https://github.com/penalosa)! - Update Devtools Patches + +## 0.1.2 + +### Patch Changes + +- [#7693](https://github.com/cloudflare/workers-sdk/pull/7693) [`65a3e35`](https://github.com/cloudflare/workers-sdk/commit/65a3e3590aff2f287c669172856512d6b29bd37f) Thanks [@emily-shen](https://github.com/emily-shen)! - chore: rebases patches on latest devtools head + +## 0.1.1 + +### Patch Changes + +- [#7258](https://github.com/cloudflare/workers-sdk/pull/7258) [`f1f508e`](https://github.com/cloudflare/workers-sdk/commit/f1f508ec1acefd7b409b77ae1070029953cca061) Thanks [@andyjessop](https://github.com/andyjessop)! - change package name from @cloudflare/wrangler-devtools to @cloudflare/chrome-devtools-patches + +## 0.1.0 + +### Minor Changes + +- [#7137](https://github.com/cloudflare/workers-sdk/pull/7137) [`1b195bd`](https://github.com/cloudflare/workers-sdk/commit/1b195bd09aef282a8a205d341579cdb7e3755d89) Thanks [@andyjessop](https://github.com/andyjessop)! - feat: update devtools patches for release + - rebases patches on top of latest devtools head + - removes CPU profiling tab + - adds performance tab + +## 0.0.1 + +### Patch Changes + +- [#4738](https://github.com/cloudflare/workers-sdk/pull/4738) [`a9f36ef`](https://github.com/cloudflare/workers-sdk/commit/a9f36ef01c6080352934cfd31c4987c5e197f8b5) Thanks [@neilsh](https://github.com/neilsh)! - fix: Fix typo in CSP diff --git a/packages/chrome-devtools-patches/Makefile b/packages/chrome-devtools-patches/Makefile new file mode 100644 index 0000000..95d3741 --- /dev/null +++ b/packages/chrome-devtools-patches/Makefile @@ -0,0 +1,38 @@ +ROOT = $(realpath .) +PATH_WITH_DEPOT = $(PATH):$(ROOT)/depot/ +# The upstream devtools commit upon which our patches are based +HEAD = b66ecf5bef3ae6c769dbc6d904ce1c54d3771227 +PATCHES = $(shell ls ${PWD}/patches/*.patch) +depot: + git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git depot + +devtools-frontend: depot + git clone https://chromium.googlesource.com/devtools/devtools-frontend + # Ensure other depot_tools can be called from gclient config + PATH="$(PATH_WITH_DEPOT)" $(ROOT)/depot/gclient config https://chromium.googlesource.com/devtools/devtools-frontend --unmanaged + git -C devtools-frontend checkout $(HEAD) + git -C devtools-frontend config user.email "workers-devprod@cloudflare.com" + git -C devtools-frontend config user.name "Workers DevProd" + git -C devtools-frontend am $(PATCHES) + +devtools-frontend/out/Default/gen/front_end: devtools-frontend + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" $(ROOT)/depot/gclient sync + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" $(ROOT)/depot/gn gen out/Default + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" $(ROOT)/depot/autoninja -C out/Default + +publish: cleanup devtools-frontend/out/Default/gen/front_end + npx wrangler deploy + +publish-preview: cleanup devtools-frontend/out/Default/gen/front_end + npx wrangler versions upload + +cleanup: + rm -rf devtools-frontend .gclient* .cipd node_modules depot + +test: + git -C devtools-frontend am $(PATCHES) + +dev: + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" npm run install-deps + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" gn gen out/Default --args="devtools_skip_typecheck=true" + cd devtools-frontend && PATH="$(PATH_WITH_DEPOT)" npm run watch diff --git a/packages/chrome-devtools-patches/README.md b/packages/chrome-devtools-patches/README.md new file mode 100644 index 0000000..6f12095 --- /dev/null +++ b/packages/chrome-devtools-patches/README.md @@ -0,0 +1,90 @@ +# Workers Devtools + +This package contains a Workers specific version of Chrome Devtools that is used by the Wrangler dev command and other applications. It is a customized fork of Chrome DevTools specifically tailored for debugging Cloudflare Workers. This package provides Worker-specific functionality through carefully maintained patches on top of Chrome DevTools. + +## Overview + +This package is used across multiple Cloudflare products: + +- Workers Playground (`workers-playground`) +- Quick Editor (`@cloudflare/quick-edit`) +- Wrangler CLI via the `InspectorProxy` + +## Features + +Our customized DevTools implementation provides: + +- Source code viewing and live updates +- Network request inspection +- Worker-specific UI optimizations + +## Development + +We maintain this fork by applying patches on top of Chrome DevTools. These patches need to be periodically rebased as Chrome DevTools evolves. + +**Key Development Tasks:** + +- Generating patches from our customizations +- Rebasing patches onto new Chrome DevTools versions +- Testing functionality across all integration points + +## Updating DevTools + +We perform quarterly updates to stay current with upstream Chrome DevTools. The update process involves: + +1. Cloning the devtools-frontend repo +2. Applying our existing patches +3. Rebasing onto the latest Chrome DevTools +4. Regenerating patches +5. Thorough testing across all integration points + +**For detailed instructions on updating DevTools, please refer to our internal documentation on keeping devtools up-to-date.** + +## Testing + +Two methods are available for testing updates: + +**Local Development:** + +- Build and serve DevTools locally +- Test against local Playground instance +- Make targeted fixes as needed + +**Preview Builds:** + +On any pull request to the repo on GitHub, you can add the `preview:chrome-devtools-patches` label to trigger a preview build of the DevTools frontend. This is useful because it will allow you to manually test your changes in a live environment, and with one-click. + +Once the preview is built, you will see a comment on the PR with a link to the live URL. + +## Acceptance Criteria + +Our DevTools implementation must maintain full functionality across: + +- Console operations (logging, errors, filters) +- Source code viewing and debugging +- Network request inspection +- Worker-specific UI customizations + +## Contributing + +When making changes: + +- Keep patches minimal and targeted +- Prefer CSS-based UI modifications +- Test thoroughly across all integration points +- Document any new patches or modifications + +## Deployment + +This package is deployed as a Cloudflare Workers + Assets project. The static DevTools frontend is served directly from Workers Assets, configured via `wrangler.jsonc`. + +Deployments are managed by GitHub Actions: + +- deploy-previews.yml: + - Runs on any PR that has the `preview:chrome-devtools-patches` label. + - Uploads a preview version (without activating it in production) via `wrangler versions upload`. + - The preview URL is posted as a comment on the PR. +- changesets.yml: + - Runs when a "Version Packages" PR, containing a changeset that touches this package, is merged to `main`. + - Deploys this package to production via `wrangler deploy`. + - Production is accessible via the custom domain [https://devtools.devprod.cloudflare.dev/]. diff --git a/packages/chrome-devtools-patches/package.json b/packages/chrome-devtools-patches/package.json new file mode 100644 index 0000000..ee54c86 --- /dev/null +++ b/packages/chrome-devtools-patches/package.json @@ -0,0 +1,26 @@ +{ + "name": "@cloudflare/chrome-devtools-patches", + "version": "0.1.5", + "private": true, + "description": "Chrome Devtools hosted for easy use with Workers tooling and applications (Wrangler, Playground, Quick Editor).", + "homepage": "https://github.com/cloudflare/workers-sdk#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "BSD-3-Clause", + "author": "workers-devprod@cloudflare.com", + "scripts": { + "deploy": "CLOUDFLARE_ACCOUNT_ID=e35fd947284363a46fd7061634477114 make publish", + "deploy:preview": "CLOUDFLARE_ACCOUNT_ID=e35fd947284363a46fd7061634477114 make publish-preview", + "testenv": "make testenv" + }, + "devDependencies": { + "wrangler": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "deploy": true + } +} diff --git a/packages/chrome-devtools-patches/patches/0001-Expand-Browser-support-make-it-work-in-Firefox-Safar.patch b/packages/chrome-devtools-patches/patches/0001-Expand-Browser-support-make-it-work-in-Firefox-Safar.patch new file mode 100644 index 0000000..14e0ecc --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0001-Expand-Browser-support-make-it-work-in-Firefox-Safar.patch @@ -0,0 +1,128 @@ +From 261ba678389218354cb8f9dce78483cf4f09716f Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 14:15:43 +0100 +Subject: [PATCH 1/8] Expand Browser support (make it work in Firefox & Safari) + +* Basic support for text colour in dark mode in browsers + that don't implement :host-context + +* A few firefox-specific patches (with detailed inline comments) + +* A (vendored) custom elements polyfill for Safari + +Acceptance criteria: +If updating the commit of devtools upon which these patches are based, make sure this patch: +* Displays console.log() output with sufficient contrast in dark mode +* Allows devtools to load in Safari (loading with _any_ UI is sufficent to check whether this has worked) +* Supports viewing a list of requests in the network panel in Firefox (without the patches here this goes very obviously wrong) +--- + front_end/core/dom_extension/DOMExtension.ts | 2 +- + front_end/entrypoint_template.html | 38 ++++++++++++++++++- + front_end/entrypoints/js_app/js_app.ts | 3 ++ + .../legacy/components/data_grid/DataGrid.ts | 5 +++ + 4 files changed, 46 insertions(+), 2 deletions(-) + +diff --git a/front_end/core/dom_extension/DOMExtension.ts b/front_end/core/dom_extension/DOMExtension.ts +index 202c680a30..a639437998 100644 +--- a/front_end/core/dom_extension/DOMExtension.ts ++++ b/front_end/core/dom_extension/DOMExtension.ts +@@ -133,7 +133,7 @@ Node.prototype.getComponentSelection = function(): Selection|null { + while (parent && parent.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) { + parent = parent.parentNode; + } +- return parent instanceof ShadowRoot ? parent.getSelection() : this.window().getSelection(); ++ return parent instanceof ShadowRoot ? (parent?.getSelection?.() ?? this.window().getSelection()) : this.window().getSelection(); + }; + + Node.prototype.hasSelection = function(): boolean { +diff --git a/front_end/entrypoint_template.html b/front_end/entrypoint_template.html +index 64d55d4c14..07972be81f 100644 +--- a/front_end/entrypoint_template.html ++++ b/front_end/entrypoint_template.html +@@ -13,9 +13,45 @@ + background-color: rgb(41 42 45); + } + } ++ ++ .platform-mac { ++ --monospace-font-size: 11px; ++ --monospace-font-family: menlo, monospace; ++ --source-code-font-size: 11px; ++ --source-code-font-family: menlo, monospace; ++ } ++ ++ .platform-windows { ++ --monospace-font-size: 12px; ++ --monospace-font-family: consolas, lucida console, courier new, monospace; ++ --source-code-font-size: 12px; ++ --source-code-font-family: consolas, lucida console, courier new, monospace; ++ } ++ ++ .platform-linux { ++ --monospace-font-size: 11px; ++ --monospace-font-family: dejavu sans mono, monospace; ++ --source-code-font-size: 11px; ++ --source-code-font-family: dejavu sans mono, monospace; ++ } ++ ++ .-theme-with-dark-background .platform-linux, ++ .-theme-with-dark-background .platform-mac { ++ --override-text-color: rgb(189 198 207); ++ } ++ ++ .cm-editor + .cm-editor { ++ display: 'none', ++ } + +- ++ + ++ + + + +diff --git a/front_end/entrypoints/js_app/js_app.ts b/front_end/entrypoints/js_app/js_app.ts +index a7f4827d68..2dbb4eca42 100644 +--- a/front_end/entrypoints/js_app/js_app.ts ++++ b/front_end/entrypoints/js_app/js_app.ts +@@ -44,6 +44,9 @@ async function loadSourcesModule(): Promise { + } + return loadedSourcesModule; + } ++ ++// This is not supported in Firefox: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView ++Element.prototype.scrollIntoViewIfNeeded = Element.prototype.scrollIntoView; + export class JsMainImpl implements Common.Runnable.Runnable { + static instance(opts: {forceNew: boolean|null} = {forceNew: null}): JsMainImpl { + const {forceNew} = opts; +diff --git a/front_end/ui/legacy/components/data_grid/DataGrid.ts b/front_end/ui/legacy/components/data_grid/DataGrid.ts +index 70820e38a1..5c9e25c522 100644 +--- a/front_end/ui/legacy/components/data_grid/DataGrid.ts ++++ b/front_end/ui/legacy/components/data_grid/DataGrid.ts +@@ -223,6 +223,8 @@ export class DataGridImpl extends Common.ObjectWrapper.ObjectWrapper *under* the ++ (this.dataTableBody as HTMLElement).style.height = 'calc(100% - 27px)'; + this.topFillerRow = this.dataTableBody.createChild('tr', 'data-grid-filler-row revealed'); + UI.ARIAUtils.setHidden(this.topFillerRow, true); + this.bottomFillerRow = this.dataTableBody.createChild('tr', 'data-grid-filler-row revealed'); +@@ -535,6 +537,9 @@ export class DataGridImpl extends Common.ObjectWrapper.ObjectWrapper +Date: Fri, 25 Oct 2024 16:06:06 +0100 +Subject: [PATCH 2/8] Setup Cloudflare devtools target type + +--- + front_end/core/sdk/Target.ts | 5 +++++ + front_end/entrypoints/js_app/js_app.ts | 2 +- + 2 files changed, 6 insertions(+), 1 deletion(-) + +diff --git a/front_end/core/sdk/Target.ts b/front_end/core/sdk/Target.ts +index 2437d6aac1..e9a3318268 100644 +--- a/front_end/core/sdk/Target.ts ++++ b/front_end/core/sdk/Target.ts +@@ -98,6 +98,10 @@ export class Target extends ProtocolClient.InspectorBackend.TargetBase { + break; + case Type.NODE_WORKER: + this.#capabilitiesMask = Capability.JS | Capability.NETWORK | Capability.TARGET; ++ break; ++ case Type.CLOUDFLARE: ++ this.#capabilitiesMask = Capability.JS | Capability.NETWORK; ++ break; + } + this.#typeInternal = type; + this.#parentTargetInternal = parentTarget; +@@ -298,6 +302,7 @@ export enum Type { + AUCTION_WORKLET = 'auction-worklet', + WORKLET = 'worklet', + TAB = 'tab', ++ CLOUDFLARE = 'cloudflare', + NODE_WORKER = 'node-worker', + } + +diff --git a/front_end/entrypoints/js_app/js_app.ts b/front_end/entrypoints/js_app/js_app.ts +index 2dbb4eca42..814bc1fc70 100644 +--- a/front_end/entrypoints/js_app/js_app.ts ++++ b/front_end/entrypoints/js_app/js_app.ts +@@ -61,7 +61,7 @@ export class JsMainImpl implements Common.Runnable.Runnable { + Host.userMetrics.actionTaken(Host.UserMetrics.Action.ConnectToNodeJSDirectly); + void SDK.Connections.initMainConnection(async () => { + const target = SDK.TargetManager.TargetManager.instance().createTarget( +- 'main', i18nString(UIStrings.main), SDK.Target.Type.NODE, null); ++ 'main', i18nString(UIStrings.main), SDK.Target.Type.CLOUDFLARE, null); + void target.runtimeAgent().invoke_runIfWaitingForDebugger(); + }, Components.TargetDetachedDialog.TargetDetachedDialog.connectionLost); + } +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/patches/0003-Add-ping-to-improve-connection-stability.-Without-th.patch b/packages/chrome-devtools-patches/patches/0003-Add-ping-to-improve-connection-stability.-Without-th.patch new file mode 100644 index 0000000..22f8e74 --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0003-Add-ping-to-improve-connection-stability.-Without-th.patch @@ -0,0 +1,45 @@ +From bcba740e8dd373acf74a8d7f82d93107e2bf4a08 Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 15:04:17 +0100 +Subject: [PATCH 3/8] Add ping to improve connection stability. Without this, + we see frequent "Devtools disconnected" messages in the dash and Workers + Playground + +--- + front_end/core/protocol_client/InspectorBackend.ts | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/front_end/core/protocol_client/InspectorBackend.ts b/front_end/core/protocol_client/InspectorBackend.ts +index 32948c2dbd..27dfa1abe3 100644 +--- a/front_end/core/protocol_client/InspectorBackend.ts ++++ b/front_end/core/protocol_client/InspectorBackend.ts +@@ -256,6 +256,7 @@ export class SessionRouter { + proxyConnection: ((Connection | undefined)|null), + }>(); + #pendingScripts: Array<() => void> = []; ++ #pingInterval: ReturnType; + + constructor(connection: Connection) { + this.#connectionInternal = connection; +@@ -266,11 +267,18 @@ export class SessionRouter { + this.#connectionInternal.setOnMessage(this.onMessage.bind(this)); + + this.#connectionInternal.setOnDisconnect(reason => { ++ clearInterval(this.#pingInterval); + const session = this.#sessions.get(''); + if (session) { + session.target.dispose(reason); + } + }); ++ this.#pingInterval = setInterval(() => { ++ this.#connectionInternal.sendRawMessage(JSON.stringify({ ++ method: 'Runtime.getIsolateId', ++ id: this.nextMessageId(), ++ })); ++ }, 10_000); + } + + registerSession(target: TargetBase, sessionId: string, proxyConnection?: Connection|null): void { +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/patches/0004-Support-viewing-source-files-over-the-network.-This-.patch b/packages/chrome-devtools-patches/patches/0004-Support-viewing-source-files-over-the-network.-This-.patch new file mode 100644 index 0000000..3abadff --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0004-Support-viewing-source-files-over-the-network.-This-.patch @@ -0,0 +1,158 @@ +From b1f97bf51177076f6abb501cf8fe57a77640792e Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 15:26:38 +0100 +Subject: [PATCH 4/8] Support viewing source files over the network. This + consists of: + +* Replacing the default file viewers (Workspace & Snippets), with a network based one called "Cloudflare" (see sources-meta.ts & js_app.ts) + +* Supporting a `?domain=` query param to customise the network name. Without this, the protocol is used (i.e. `file://`), which isn't super friendly (see NavigatorView.ts) + +* Enable JUST_MY_CODE to hide Wrangler's injected middleware (see MainImpl.ts) + +* Enable AUTHORED_DEPLOYED_GROUPING for a better grouping between source-mapped and compiled code (see MainImpl.ts) + +* Support the mechanism by which Wrangler provides sourcemaps, the wrangler-file:// protocol (see PageResourceLoader.ts & ParsedURL.ts) +--- + front_end/core/common/ParsedURL.ts | 2 +- + front_end/core/sdk/PageResourceLoader.ts | 4 +++- + front_end/entrypoints/js_app/js_app.ts | 16 ++++++-------- + front_end/entrypoints/main/MainImpl.ts | 2 ++ + front_end/panels/sources/NavigatorView.ts | 5 +++-- + front_end/panels/sources/sources-meta.ts | 26 ----------------------- + 6 files changed, 15 insertions(+), 40 deletions(-) + +diff --git a/front_end/core/common/ParsedURL.ts b/front_end/core/common/ParsedURL.ts +index d01ab1856d..e29c0eab46 100644 +--- a/front_end/core/common/ParsedURL.ts ++++ b/front_end/core/common/ParsedURL.ts +@@ -365,7 +365,7 @@ export class ParsedURL { + static completeURL(baseURL: Platform.DevToolsPath.UrlString, href: string): Platform.DevToolsPath.UrlString|null { + // Return special URLs as-is. + if (href.startsWith('data:') || href.startsWith('blob:') || href.startsWith('javascript:') || +- href.startsWith('mailto:')) { ++ href.startsWith('mailto:') || href.startsWith('wrangler:')) { + return href as Platform.DevToolsPath.UrlString; + } + +diff --git a/front_end/core/sdk/PageResourceLoader.ts b/front_end/core/sdk/PageResourceLoader.ts +index 722bf5f80a..cede6fbc20 100644 +--- a/front_end/core/sdk/PageResourceLoader.ts ++++ b/front_end/core/sdk/PageResourceLoader.ts +@@ -381,7 +381,9 @@ export class PageResourceLoader extends Common.ObjectWrapper.ObjectWrapper +Date: Fri, 25 Oct 2024 15:05:56 +0100 +Subject: [PATCH 5/8] Support forcing the devtools theme via a query parameter, + to enable it to fit in with the Cloudflare dashboard when embedded. + +To test, make sure that `?theme=dark`, `?theme=default`, and `?theme=systemPreferred` all correctly force the theme of devtools +--- + front_end/ui/legacy/theme_support/ThemeSupport.ts | 11 ++++++++--- + 1 file changed, 8 insertions(+), 3 deletions(-) + +diff --git a/front_end/ui/legacy/theme_support/ThemeSupport.ts b/front_end/ui/legacy/theme_support/ThemeSupport.ts +index 298ba78952..43ee9341ec 100644 +--- a/front_end/ui/legacy/theme_support/ThemeSupport.ts ++++ b/front_end/ui/legacy/theme_support/ThemeSupport.ts +@@ -152,14 +152,19 @@ export class ThemeSupport extends EventTarget { + } + + #applyThemeToDocument(document: Document): void { ++ // Get theme from url, to allow for theme inheritance from the Cloudflare dashboard ++ const urlParams = new URLSearchParams(document.location.search); ++ const theme = urlParams.get('theme') ?? 'systemPreferred'; ++ + const isForcedColorsMode = window.matchMedia('(forced-colors: active)').matches; + const systemPreferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'; + +- const useSystemPreferred = this.setting.get() === 'systemPreferred' || isForcedColorsMode; +- this.themeNameInternal = useSystemPreferred ? systemPreferredTheme : this.setting.get(); ++ const useSystemPreferred = theme === 'systemPreferred' || isForcedColorsMode; ++ this.themeNameInternal = useSystemPreferred ? systemPreferredTheme : theme; + document.documentElement.classList.toggle('theme-with-dark-background', this.themeNameInternal === 'dark'); + +- const useChromeTheme = Common.Settings.moduleSetting('chrome-theme-colors').get(); ++ // Disable this—it adds blue headers which are out of keeping with Cloudflare styling ++ const useChromeTheme = false; + const isIncognito = Root.Runtime.hostConfig.isOffTheRecord === true; + // Baseline is the name of Chrome's default color theme and there are two of these: default and grayscale. + // The collective name for the rest of the color themes is dynamic. +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/patches/0006-All-about-the-network-tab.patch b/packages/chrome-devtools-patches/patches/0006-All-about-the-network-tab.patch new file mode 100644 index 0000000..3164788 --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0006-All-about-the-network-tab.patch @@ -0,0 +1,116 @@ +From 1729d533ea8866ee71a5e2170253f7cd6f850e0c Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 16:05:12 +0100 +Subject: [PATCH 6/8] All about the network tab! + +* Display the _content_ of responses (using cfResponse.body, a Cloudflare-specific extension) + +* Enable the network tab for the js_app type + +* Hide unsupported UI (cache disabling & network throttling) +--- + front_end/core/sdk/NetworkManager.ts | 8 ++++++- + front_end/entrypoints/js_app/js_app.ts | 1 + + front_end/panels/network/NetworkPanel.ts | 27 ------------------------ + 3 files changed, 8 insertions(+), 28 deletions(-) + +diff --git a/front_end/core/sdk/NetworkManager.ts b/front_end/core/sdk/NetworkManager.ts +index 1e50c6329f..756679235b 100644 +--- a/front_end/core/sdk/NetworkManager.ts ++++ b/front_end/core/sdk/NetworkManager.ts +@@ -34,6 +34,7 @@ + + import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; + import * as Protocol from '../../generated/protocol.js'; ++import { ContentData } from '../../models/text_utils/ContentData.js'; + import * as TextUtils from '../../models/text_utils/text_utils.js'; + import * as Common from '../common/common.js'; + import type {Serializer} from '../common/Settings.js'; +@@ -846,7 +847,7 @@ export class NetworkDispatcher implements ProtocolProxyApi.NetworkDispatcher { + this.updateNetworkRequest(networkRequest); + } + +- loadingFinished({requestId, timestamp: finishTime, encodedDataLength}: Protocol.Network.LoadingFinishedEvent): void { ++ loadingFinished({requestId, timestamp: finishTime, encodedDataLength, cfResponse}: Protocol.Network.LoadingFinishedEvent & { cfResponse?: Omit }): void { + let networkRequest: NetworkRequest|null|undefined = this.#requestsById.get(requestId); + if (!networkRequest) { + networkRequest = this.maybeAdoptMainResourceRequest(requestId); +@@ -855,6 +856,11 @@ export class NetworkDispatcher implements ProtocolProxyApi.NetworkDispatcher { + return; + } + this.getExtraInfoBuilder(requestId).finished(); ++ if (cfResponse !== undefined) { ++ networkRequest.setContentDataProvider(async () => { ++ return new ContentData(cfResponse.body, cfResponse.base64Encoded, networkRequest.mimeType); ++ }); ++ } + this.finishNetworkRequest(networkRequest, finishTime, encodedDataLength); + this.#manager.dispatchEventToListeners(Events.LoadingFinished, networkRequest); + } +diff --git a/front_end/entrypoints/js_app/js_app.ts b/front_end/entrypoints/js_app/js_app.ts +index 893ac3533f..937e646ab5 100644 +--- a/front_end/entrypoints/js_app/js_app.ts ++++ b/front_end/entrypoints/js_app/js_app.ts +@@ -4,6 +4,7 @@ + + import '../shell/shell.js'; + import '../../panels/js_timeline/js_timeline-meta.js'; ++import '../../panels/network/network-meta.js'; + import '../../panels/mobile_throttling/mobile_throttling-meta.js'; + import '../../panels/network/network-meta.js'; + +diff --git a/front_end/panels/network/NetworkPanel.ts b/front_end/panels/network/NetworkPanel.ts +index f70065800e..ac1316662b 100644 +--- a/front_end/panels/network/NetworkPanel.ts ++++ b/front_end/panels/network/NetworkPanel.ts +@@ -76,14 +76,6 @@ const UIStrings = { + *@description Text to preserve the log after refreshing + */ + preserveLog: 'Preserve log', +- /** +- *@description Text to disable cache while DevTools is open +- */ +- disableCacheWhileDevtoolsIsOpen: 'Disable cache while DevTools is open', +- /** +- *@description Text in Network Config View of the Network panel +- */ +- disableCache: 'Disable cache', + /** + *@description Tooltip text that appears when hovering over the largeicon settings gear in show settings pane setting in network panel of the network panel + */ +@@ -181,10 +173,6 @@ const UIStrings = { + *@description Text in Network Panel that is displayed when frames are being fetched. + */ + fetchingFrames: 'Fetching frames…', +- /** +- * @description Text of a button in the Network panel's toolbar that open Network Conditions panel in the drawer. +- */ +- moreNetworkConditions: 'More network conditions…', + } as const; + const str_ = i18n.i18n.registerUIStrings('panels/network/NetworkPanel.ts', UIStrings); + const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); +@@ -456,21 +444,6 @@ export class NetworkPanel extends UI.Panel.Panel implements + this.panelToolbar.appendToolbarItem(new UI.Toolbar.ToolbarSettingCheckbox( + this.preserveLogSetting, i18nString(UIStrings.doNotClearLogOnPageReload), i18nString(UIStrings.preserveLog))); + +- this.panelToolbar.appendSeparator(); +- const disableCacheCheckbox = new UI.Toolbar.ToolbarSettingCheckbox( +- Common.Settings.Settings.instance().moduleSetting('cache-disabled'), +- i18nString(UIStrings.disableCacheWhileDevtoolsIsOpen), i18nString(UIStrings.disableCache)); +- this.panelToolbar.appendToolbarItem(disableCacheCheckbox); +- +- this.panelToolbar.appendToolbarItem(this.throttlingSelect); +- +- const networkConditionsButton = new UI.Toolbar.ToolbarButton( +- i18nString(UIStrings.moreNetworkConditions), 'network-settings', undefined, 'network-conditions'); +- networkConditionsButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => { +- void UI.ViewManager.ViewManager.instance().showView('network.config'); +- }, this); +- this.panelToolbar.appendToolbarItem(networkConditionsButton); +- + this.rightToolbar.appendToolbarItem(new UI.Toolbar.ToolbarItem(this.progressBarContainer)); + this.rightToolbar.appendSeparator(); + this.rightToolbar.appendToolbarItem(new UI.Toolbar.ToolbarSettingToggle( +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/patches/0007-Limit-heap-profiling-modes-available.patch b/packages/chrome-devtools-patches/patches/0007-Limit-heap-profiling-modes-available.patch new file mode 100644 index 0000000..dcde323 --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0007-Limit-heap-profiling-modes-available.patch @@ -0,0 +1,31 @@ +From c43fb074f72c73e4c7ad9dd02c061870298a4347 Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 16:07:24 +0100 +Subject: [PATCH 7/8] Limit heap profiling modes available + +--- + front_end/panels/profiler/HeapProfilerPanel.ts | 8 ++------ + 1 file changed, 2 insertions(+), 6 deletions(-) + +diff --git a/front_end/panels/profiler/HeapProfilerPanel.ts b/front_end/panels/profiler/HeapProfilerPanel.ts +index b25d9e65fc..9a19052de2 100644 +--- a/front_end/panels/profiler/HeapProfilerPanel.ts ++++ b/front_end/panels/profiler/HeapProfilerPanel.ts +@@ -25,12 +25,8 @@ export class HeapProfilerPanel extends ProfilesPanel implements UI.ContextMenu.P + UI.ActionRegistration.ActionDelegate { + constructor() { + const registry = instance; +- const profileTypes = [ +- registry.heapSnapshotProfileType, +- registry.trackingHeapSnapshotProfileType, +- registry.samplingHeapProfileType, +- registry.detachedElementProfileType, +- ]; ++ const profileTypes = ++ [registry.heapSnapshotProfileType]; + super('heap-profiler', profileTypes as ProfileType[], 'profiler.heap-toggle-recording'); + } + +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/patches/0008-Use-the-worker-name-as-the-title-for-the-Javascript-.patch b/packages/chrome-devtools-patches/patches/0008-Use-the-worker-name-as-the-title-for-the-Javascript-.patch new file mode 100644 index 0000000..d0e393f --- /dev/null +++ b/packages/chrome-devtools-patches/patches/0008-Use-the-worker-name-as-the-title-for-the-Javascript-.patch @@ -0,0 +1,31 @@ +From 641e6a0eb3f7cf138168185f8a51cd93b1d0db74 Mon Sep 17 00:00:00 2001 +From: Workers DevProd +Date: Fri, 25 Oct 2024 16:11:10 +0100 +Subject: [PATCH 8/8] Use the worker name as the title for the Javascript + Isolate name in the Performance tab + +--- + front_end/panels/timeline/IsolateSelector.ts | 6 +++++- + 1 file changed, 5 insertions(+), 1 deletion(-) + +diff --git a/front_end/panels/timeline/IsolateSelector.ts b/front_end/panels/timeline/IsolateSelector.ts +index eea81e20dd..9d19e75d79 100644 +--- a/front_end/panels/timeline/IsolateSelector.ts ++++ b/front_end/panels/timeline/IsolateSelector.ts +@@ -52,8 +52,12 @@ export class IsolateSelector extends UI.Toolbar.ToolbarItem implements SDK.Isola + const name = SDK.TargetManager.TargetManager.instance().rootTarget() !== target ? target.name() : ''; + const parsedURL = new Common.ParsedURL.ParsedURL(target.inspectedURL()); + const domain = parsedURL.isValid ? parsedURL.domain() : ''; ++ ++ const query = new URLSearchParams(location.search); ++ const workerName = query.get('domain'); ++ + const title = +- target.decorateLabel(domain && name ? `${domain}: ${name}` : name || domain || i18nString(UIStrings.empty)); ++ target.decorateLabel(domain && name ? `${domain}: ${name}` : name || domain || workerName || i18nString(UIStrings.empty)); + modelCountByName.set(title, (modelCountByName.get(title) || 0) + 1); + } + itemForIsolate.removeChildren(); +-- +2.39.5 (Apple Git-154) + diff --git a/packages/chrome-devtools-patches/wrangler.jsonc b/packages/chrome-devtools-patches/wrangler.jsonc new file mode 100644 index 0000000..68f024d --- /dev/null +++ b/packages/chrome-devtools-patches/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "cloudflare-devtools", + "compatibility_date": "2025-07-01", + "assets": { + "directory": "devtools-frontend/out/Default/gen/front_end", + }, + "preview_urls": true, + "workers_dev": true, +} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..f8d5b33 --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,201 @@ +# @cloudflare/cli + +## 0.1.13 + +### Patch Changes + +- Updated dependencies [[`0283a1f`](https://github.com/cloudflare/workers-sdk/commit/0283a1fcdc635244f731010422e513e8b4ab0be3)]: + - @cloudflare/workers-utils@0.26.0 + +## 0.1.12 + +### Patch Changes + +- Updated dependencies [[`aad35b7`](https://github.com/cloudflare/workers-sdk/commit/aad35b79d07df1bb764a4a5912d6b4328a34474b), [`1ac96a1`](https://github.com/cloudflare/workers-sdk/commit/1ac96a14b7fb022acada114ab8793fe8a4ba79a5), [`1ca8d8f`](https://github.com/cloudflare/workers-sdk/commit/1ca8d8f0bbd012a1d65cabadf7b6987b252775e9)]: + - @cloudflare/workers-utils@0.25.1 + +## 0.1.11 + +### Patch Changes + +- Updated dependencies [[`aa5d580`](https://github.com/cloudflare/workers-sdk/commit/aa5d5801450b7e4417bfdbd477f86de3a4bc6933)]: + - @cloudflare/workers-utils@0.25.0 + +## 0.1.10 + +### Patch Changes + +- Updated dependencies [[`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359), [`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359)]: + - @cloudflare/workers-utils@0.24.0 + +## 0.1.9 + +### Patch Changes + +- Updated dependencies [[`673b09e`](https://github.com/cloudflare/workers-sdk/commit/673b09e0fa26368125fb527596a8eb5d31c27302), [`5dfb788`](https://github.com/cloudflare/workers-sdk/commit/5dfb788595a2104b4b0922cfce3d69a2f1d881eb)]: + - @cloudflare/workers-utils@0.23.2 + +## 0.1.8 + +### Patch Changes + +- Updated dependencies [[`ecfdd5a`](https://github.com/cloudflare/workers-sdk/commit/ecfdd5a6c60b9c6f99c28f9294da656933c2a5fd)]: + - @cloudflare/workers-utils@0.23.1 + +## 0.1.7 + +### Patch Changes + +- Updated dependencies [[`c6c61b5`](https://github.com/cloudflare/workers-sdk/commit/c6c61b59431443b2bcda25f3af7624dd2ce19b9b), [`b502d54`](https://github.com/cloudflare/workers-sdk/commit/b502d5445b9e9e030020a3d65c0334507393aa64), [`c4f45e8`](https://github.com/cloudflare/workers-sdk/commit/c4f45e8b8694c60fb1808f7fbb130e4b4893d20c)]: + - @cloudflare/workers-utils@0.23.0 + +## 0.1.6 + +### Patch Changes + +- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions + + Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward. + +- Updated dependencies [[`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5)]: + - @cloudflare/workers-utils@0.22.1 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [[`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`599b27a`](https://github.com/cloudflare/workers-sdk/commit/599b27aafb9bc432524a35eb4e5a414de21bef41), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee)]: + - @cloudflare/workers-utils@0.22.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [[`90092c0`](https://github.com/cloudflare/workers-sdk/commit/90092c0bca526e2e08a25fe7969534426eb6fd9f)]: + - @cloudflare/workers-utils@0.21.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [[`248bc08`](https://github.com/cloudflare/workers-sdk/commit/248bc08152cf9f792d98c8c78f8fb1417b1bb3b3)]: + - @cloudflare/workers-utils@0.21.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`f3fed88`](https://github.com/cloudflare/workers-sdk/commit/f3fed8859b612d424388fe45a1d638cf6b1c42c7)]: + - @cloudflare/workers-utils@0.20.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`2b8c0cc`](https://github.com/cloudflare/workers-sdk/commit/2b8c0ccb9ede7487bd96cfc51b3262a717bb532c)]: + - @cloudflare/workers-utils@0.19.0 + +## 0.1.0 + +### Minor Changes + +- [#13651](https://github.com/cloudflare/workers-sdk/pull/13651) [`47ac63f`](https://github.com/cloudflare/workers-sdk/commit/47ac63f05ca86d781110490ff21ff88f2828fbbf) Thanks [@penalosa](https://github.com/penalosa)! - Publish `@cloudflare/cli-shared-helpers` and `@cloudflare/workers-utils` to npm + +### Patch Changes + +- Updated dependencies [[`5680287`](https://github.com/cloudflare/workers-sdk/commit/56802879641c123ee11160d77ecaf104915cd826), [`47ac63f`](https://github.com/cloudflare/workers-sdk/commit/47ac63f05ca86d781110490ff21ff88f2828fbbf)]: + - @cloudflare/workers-utils@0.18.0 + +## 1.4.0 + +### Minor Changes + +- [#13144](https://github.com/cloudflare/workers-sdk/pull/13144) [`db60b94`](https://github.com/cloudflare/workers-sdk/commit/db60b94d9620e7608df3e98876d5df4fde952ecf) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add gitignore helpers for appending Wrangler-related entries + + New `maybeAppendWranglerToGitIgnore` and `maybeAppendWranglerToGitIgnoreLikeFile` functions that automatically append Wrangler-related entries (`.wrangler`, `.dev.vars*`, `.env*`, and their negated example patterns) to `.gitignore` or similar ignore files. Existing entries are detected and skipped to avoid duplicates. + +## 1.3.0 + +### Minor Changes + +- [#13068](https://github.com/cloudflare/workers-sdk/pull/13068) [`e631a94`](https://github.com/cloudflare/workers-sdk/commit/e631a946466ded834763497d094da9e93d3d3721) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add `runCommand` and `quoteShellArgs`, `installPackages` and `installWrangler` utilities to `@cloudflare/cli/command` + + These utilities are now available from `@cloudflare/cli` as dedicated sub-path exports: `runCommand` and `quoteShellArgs` via `@cloudflare/cli/command`, and `installPackages` and `installWrangler` via `@cloudflare/cli/packages`. This makes them reusable across packages in the SDK without duplication. + +## 1.2.1 + +### Patch Changes + +- [#11940](https://github.com/cloudflare/workers-sdk/pull/11940) [`69ff962`](https://github.com/cloudflare/workers-sdk/commit/69ff9620487a6ae979f369eb1dbac887ce46e246) Thanks [@penalosa](https://github.com/penalosa)! - Mark macOS version compatibility errors as user errors + + When checking macOS version compatibility, the CLI now throws `UserError` instead of generic `Error`. This ensures that version incompatibility issues are properly classified as user-facing errors that shouldn't be reported to Sentry. + +- [#11967](https://github.com/cloudflare/workers-sdk/pull/11967) [`202c59e`](https://github.com/cloudflare/workers-sdk/commit/202c59e4f4f28419fb6ac0aa8c7dc3960a0c8d3e) Thanks [@emily-shen](https://github.com/emily-shen)! - chore: update undici + + The following dependency versions have been updated: + + | Dependency | From | To | + | ---------- | ------ | ------ | + | undici | 7.14.0 | 7.18.2 | + +## 1.2.0 + +### Minor Changes + +- [#11578](https://github.com/cloudflare/workers-sdk/pull/11578) [`4201472`](https://github.com/cloudflare/workers-sdk/commit/4201472291fa1c864dbcca40c173a76e5b571a04) Thanks [@gpanders](https://github.com/gpanders)! - Add showCursor helper function to show or hide the cursor in the terminal + +## 1.1.4 + +### 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` + +## 1.1.3 + +### Patch Changes + +- [#10764](https://github.com/cloudflare/workers-sdk/pull/10764) [`79a6b7d`](https://github.com/cloudflare/workers-sdk/commit/79a6b7dd811fea5a413b084fcd281915a418a85a) Thanks [@emily-shen](https://github.com/emily-shen)! - fix: respect the log level set by wrangler when logging using @cloudflare/cli + +## 1.1.2 + +### Patch Changes + +- [#10492](https://github.com/cloudflare/workers-sdk/pull/10492) [`8ae9323`](https://github.com/cloudflare/workers-sdk/commit/8ae9323a5c45f3efe3685e36b1536cc25d39fbfb) Thanks [@gpanders](https://github.com/gpanders)! - Include cursor in text prompts + +## 1.1.1 + +### Patch Changes + +- [#4768](https://github.com/cloudflare/workers-sdk/pull/4768) [`c3e410c2`](https://github.com/cloudflare/workers-sdk/commit/c3e410c2797f5c59b9ea0f63c20feef643366df2) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - ci: bump undici versions to 5.28.2 + +## 1.1.0 + +### Minor Changes + +- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added a new `SelectRefreshablePrompt` component + + This component supports infinite scrolling, and the ability to refresh the items on 'R' keypress. See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details. + +* [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added `@clack/core`'s `MultiSelectPrompt` component + + See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details. + +- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added `processArguments` helper function + + See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details. + +### Patch Changes + +- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Downgraded `chalk` dependency from `^5.2.0` to `^2.4.2` + + This was done for compatibility reasons with the version used in the `wrangler` package. See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details. + +## 1.0.0 + +### Major Changes + +- [#4189](https://github.com/cloudflare/workers-sdk/pull/4189) [`05798038`](https://github.com/cloudflare/workers-sdk/commit/05798038c85a83afb2c0e8ea9533c31a6fbe3e91) Thanks [@gabivlj](https://github.com/gabivlj)! - Move helper cli files of C3 into @cloudflare/cli and make Wrangler and C3 depend on it + +### Patch Changes + +- [#4271](https://github.com/cloudflare/workers-sdk/pull/4271) [`70016b2b`](https://github.com/cloudflare/workers-sdk/commit/70016b2bb514ea95f1ce0db3582e194c31df4c14) Thanks [@gabivlj](https://github.com/gabivlj)! - change backgrounds of statuses from red to more appropriate ones diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..5398f17 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,5 @@ +# Cloudflare CLI + +> Internal utility package for workers-sdk. Not intended for external use — APIs may change without notice. + +All things related to rendering the CLI for workers-sdk. diff --git a/packages/cli/__tests__/check-macos-version.test.ts b/packages/cli/__tests__/check-macos-version.test.ts new file mode 100644 index 0000000..6cd77a7 --- /dev/null +++ b/packages/cli/__tests__/check-macos-version.test.ts @@ -0,0 +1,161 @@ +import os from "node:os"; +import ci from "ci-info"; +import { beforeEach, describe, it, vi } from "vitest"; +import { checkMacOSVersion } from "../check-macos-version"; + +vi.mock("node:os"); +vi.mock("ci-info", () => ({ + default: { isCI: false }, + isCI: false, +})); + +const mockOs = vi.mocked(os); + +describe("checkMacOSVersion", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ci).isCI = false; + }); + + it("should not throw on non-macOS platforms", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should not throw on macOS 13.5.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("22.6.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should not throw on macOS 14.0.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("23.0.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should not throw on macOS 13.6.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("22.7.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should throw error on macOS 12.7.6", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("21.6.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow( + "Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (12.6.0)" + ); + }); + + it("should throw error on macOS 13.4.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("22.4.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow( + "Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (13.4.0)" + ); + }); + + it("should handle invalid Darwin version format gracefully", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("invalid-version"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should handle very old Darwin versions gracefully", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("19.6.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); + + it("should not throw when running in CI", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.mocked(ci).isCI = true; + mockOs.release.mockReturnValue("21.6.0"); + + expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow(); + }); +}); + +describe("checkMacOSVersion with shouldThrow=false", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ci).isCI = false; + }); + + it("should not warn on non-macOS platforms", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("should not warn on macOS 13.5.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("22.6.0"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("should warn on macOS 12.7.6", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("21.6.0"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "⚠️ Warning: Unsupported macOS version detected (12.6.0)" + ) + ); + }); + + it("should warn on macOS 13.4.0", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("22.4.0"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "⚠️ Warning: Unsupported macOS version detected (13.4.0)" + ) + ); + }); + + it("should not warn when running in CI", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.mocked(ci).isCI = true; + mockOs.release.mockReturnValue("21.6.0"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("should not warn on invalid Darwin version format", ({ expect }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mockOs.release.mockReturnValue("invalid-version"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkMacOSVersion({ shouldThrow: false }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts new file mode 100644 index 0000000..ea3e3a2 --- /dev/null +++ b/packages/cli/__tests__/cli.test.ts @@ -0,0 +1,10 @@ +import { describe, test } from "vitest"; +import { space } from ".."; + +describe("cli", () => { + test("test spaces", ({ expect }) => { + expect(space(300)).toMatchInlineSnapshot( + '"                                                                                                                                                                                                                                                                                                            "' + ); + }); +}); diff --git a/packages/cli/__tests__/command.test.ts b/packages/cli/__tests__/command.test.ts new file mode 100644 index 0000000..5491069 --- /dev/null +++ b/packages/cli/__tests__/command.test.ts @@ -0,0 +1,72 @@ +import { spawn } from "cross-spawn"; +import { afterEach, beforeEach, describe, test, vi } from "vitest"; +import { quoteShellArgs, runCommand } from "../command"; +import type { ChildProcess } from "node:child_process"; + +// We can change how the mock spawn works by setting these variables +let spawnResultCode = 0; +let spawnStdout: string | undefined = undefined; +let spawnStderr: string | undefined = undefined; + +vi.mock("cross-spawn"); + +describe("Command Helpers", () => { + afterEach(() => { + spawnResultCode = 0; + spawnStdout = undefined; + spawnStderr = undefined; + }); + + beforeEach(() => { + vi.mocked(spawn).mockImplementation(() => { + return { + on: vi.fn().mockImplementation((event, cb) => { + if (event === "close") { + cb(spawnResultCode); + } + }), + stdout: { + on(_event: "data", cb: (data: string) => void) { + if (spawnStdout !== undefined) { + cb(spawnStdout); + } + }, + }, + stderr: { + on(_event: "data", cb: (data: string) => void) { + if (spawnStderr !== undefined) { + cb(spawnStderr); + } + }, + }, + } as unknown as ChildProcess; + }); + }); + + test("runCommand", async ({ expect }) => { + await runCommand(["ls", "-l"]); + expect(spawn).toHaveBeenCalledWith("ls", ["-l"], { + stdio: "inherit", + env: process.env, + signal: expect.any(AbortSignal), + }); + }); + + describe("quoteShellArgs", () => { + test.runIf(process.platform !== "win32")("mac", async ({ expect }) => { + expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev"); + expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`'24.02 foo-bar'`); + expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual( + `'foo/10 bar/20-baz/'` + ); + }); + + test.runIf(process.platform === "win32")("windows", async ({ expect }) => { + expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev"); + expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`"24.02 foo-bar"`); + expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual( + `"foo/10 bar/20-baz/"` + ); + }); + }); +}); diff --git a/packages/cli/__tests__/gitignore.test.ts b/packages/cli/__tests__/gitignore.test.ts new file mode 100644 index 0000000..90aa28e --- /dev/null +++ b/packages/cli/__tests__/gitignore.test.ts @@ -0,0 +1,445 @@ +import { appendFileSync, existsSync, statSync, writeFileSync } from "node:fs"; +import { readFileSync } from "@cloudflare/workers-utils"; +import { beforeEach, describe, test, vi } from "vitest"; +import { + maybeAppendWranglerToGitIgnore, + maybeAppendWranglerToGitIgnoreLikeFile, +} from "../gitignore"; +import type { PathLike } from "node:fs"; +import type { Mock } from "vitest"; + +vi.mock("node:fs"); +vi.mock("@cloudflare/workers-utils"); +vi.mock("../interactive", () => ({ + spinner: () => ({ + start: vi.fn(), + stop: vi.fn(), + update: vi.fn(), + }), +})); + +describe("maybeAppendWranglerToGitIgnoreLikeFile", () => { + let writeFileSyncMock: Mock; + let appendFileSyncMock: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + + writeFileSyncMock = vi.mocked(writeFileSync); + appendFileSyncMock = vi.mocked(appendFileSync); + }); + + test("should append the wrangler section to a standard gitignore file", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .vscode` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + + # wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should not touch the file if it already contains all wrangler files", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .dev.vars* + !.dev.vars.example + .env* + !.env.example + .vscode + .wrangler + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock).not.toHaveBeenCalled(); + }); + + test("should not touch the file if it contains all wrangler files (and can cope with comments)", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .wrangler # This is for wrangler + .dev.vars* # this is for wrangler and getPlatformProxy + !.dev.vars.example # more comments + .env* # even more + !.env.example # and a final one + .vscode + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock).not.toHaveBeenCalled(); + }); + + test("should append to the file the missing wrangler files when some are already present (should add the section heading if including .wrangler and some others)", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .dev.vars* + .vscode` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + + # wrangler files + .wrangler + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should append to the file the missing wrangler files when some are already present (should not add the section heading if .wrangler already exists)", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .wrangler + .dev.vars* + .vscode` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should append to the file the missing wrangler files when some are already present (should not add the section heading if only adding .wrangler)", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .dev.vars* + !.dev.vars.example + .env* + !.env.example + .vscode` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + + .wrangler + ", + ], + ] + `); + }); + + test("when it appends to the file it doesn't include an empty line only if there was one already", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .dev.vars* + !.dev.vars.example + .env* + !.env.example + .vscode + + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + .wrangler + ", + ], + ] + `); + }); + + test("should create the file if it didn't exist already", ({ expect }) => { + mockIgnoreFile("my-project/.gitignore", ""); + // pretend that the file doesn't exist + vi.mocked(existsSync).mockImplementation(() => false); + + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + // writeFile wrote the (empty) file + expect(writeFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + "", + ], + ] + `); + + // and the correct lines were then added to it + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + "# wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should add the wildcard .dev.vars* entry even if a .dev.vars is already included", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .dev.vars + .vscode + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + # wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should not add the .env entries if some form of .env entries are already included", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + .env + .env.* + !.env.example + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + # wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + ", + ], + ] + `); + }); + + test("should not add the .wrangler entry if a .wrangler/ is already included", ({ + expect, + }) => { + mockIgnoreFile( + "my-project/.gitignore", + ` + node_modules + .wrangler/ # This is for wrangler + .vscode + ` + ); + maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + function mockIgnoreFile(path: string, content: string) { + vi.mocked(existsSync).mockImplementation( + (filePath: PathLike) => filePath === path + ); + vi.mocked(readFileSync).mockImplementation((filePath: string) => + filePath === path ? content.replace(/\n\s*/g, "\n") : "" + ); + } +}); + +describe("maybeAppendWranglerToGitIgnore", () => { + let appendFileSyncMock: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + + appendFileSyncMock = vi.mocked(appendFileSync); + + vi.mocked(statSync).mockImplementation(((path: string) => ({ + isDirectory() { + return path.endsWith(".git"); + }, + })) as typeof statSync); + }); + + test("should not create the gitignore file if neither the .git directory not the .gitingore file exist", ({ + expect, + }) => { + // no .gitignore file exists + vi.mocked(existsSync).mockImplementation(() => false); + // neither a .git directory does + vi.mocked(statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + maybeAppendWranglerToGitIgnore("my-project"); + + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + expect(appendFileSyncMock).not.toHaveBeenCalled(); + }); + + test("should create a .gitignore file when .git directory exists", ({ + expect, + }) => { + // no .gitignore file exists + vi.mocked(existsSync).mockImplementation(() => false); + vi.mocked(readFileSync).mockImplementation(() => ""); + + maybeAppendWranglerToGitIgnore("my-project"); + + // writeFileSync created the (empty) file + expect(vi.mocked(writeFileSync).mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + "", + ], + ] + `); + + // and the correct wrangler lines were appended to it + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + "# wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); + + test("should append wrangler entries when the .gitignore file exists but the .git directory does not", ({ + expect, + }) => { + // .gitignore exists + vi.mocked(existsSync).mockImplementation( + (filePath: PathLike) => filePath === "my-project/.gitignore" + ); + vi.mocked(readFileSync).mockImplementation((filePath: string) => + filePath === "my-project/.gitignore" ? "\nnode_modules\n.vscode\n" : "" + ); + // .git directory does NOT exist + vi.mocked(statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + maybeAppendWranglerToGitIgnore("my-project"); + + expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "my-project/.gitignore", + " + # wrangler files + .wrangler + .dev.vars* + !.dev.vars.example + .env* + !.env.example + ", + ], + ] + `); + }); +}); diff --git a/packages/cli/__tests__/packages.test.ts b/packages/cli/__tests__/packages.test.ts new file mode 100644 index 0000000..c0b6a55 --- /dev/null +++ b/packages/cli/__tests__/packages.test.ts @@ -0,0 +1,154 @@ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils"; +import { afterEach, describe, test, vi } from "vitest"; +import { runCommand } from "../command"; +import { installPackages, installWrangler } from "../packages"; + +vi.mock("../command"); +vi.mock("@cloudflare/workers-utils", () => ({ + readFileSync: vi.fn(), + parsePackageJSON: vi.fn(), +})); +vi.mock("node:fs/promises", () => ({ + writeFile: vi.fn(), +})); + +describe("Package Helpers", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("installPackages", async () => { + type TestCase = { + pm: "npm" | "pnpm" | "yarn" | "bun"; + initialArgs: string[]; + additionalArgs?: string[]; + }; + + const cases: TestCase[] = [ + { pm: "npm", initialArgs: ["npm", "install"] }, + { pm: "pnpm", initialArgs: ["pnpm", "install"] }, + { pm: "bun", initialArgs: ["bun", "add"] }, + { pm: "yarn", initialArgs: ["yarn", "add"] }, + ]; + + test.for(cases)( + "with $pm", + async ({ pm, initialArgs, additionalArgs }, { expect }) => { + const mockPkgJson = { + dependencies: { + foo: "^1.0.0", + bar: "^2.0.0", + baz: "^1.2.3", + }, + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson)); + vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson); + + const packages = ["foo", "bar@latest", "baz@1.2.3"]; + await installPackages(pm, packages); + + expect(vi.mocked(runCommand)).toHaveBeenCalledWith( + [...initialArgs, ...packages, ...(additionalArgs ?? [])], + expect.anything() + ); + + if (pm === "npm") { + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json")); + expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({ + dependencies: { + foo: "^1.0.0", + bar: "^2.0.0", + baz: "1.2.3", + }, + }); + } + } + ); + + const devCases: TestCase[] = [ + { pm: "npm", initialArgs: ["npm", "install", "--save-dev"] }, + { pm: "pnpm", initialArgs: ["pnpm", "install", "--save-dev"] }, + { pm: "bun", initialArgs: ["bun", "add", "-d"] }, + { pm: "yarn", initialArgs: ["yarn", "add", "-D"] }, + ]; + + test.for(devCases)( + "with $pm (dev = true)", + async ({ pm, initialArgs, additionalArgs }, { expect }) => { + const mockPkgJson = { + devDependencies: { + foo: "^1.0.0", + bar: "^2.0.0", + baz: "^1.2.3", + }, + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson)); + vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson); + + const packages = ["foo", "bar@latest", "baz@1.2.3"]; + await installPackages(pm, packages, { dev: true }); + + expect(vi.mocked(runCommand)).toHaveBeenCalledWith( + [...initialArgs, ...packages, ...(additionalArgs ?? [])], + expect.anything() + ); + + if (pm === "npm") { + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json")); + expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({ + devDependencies: { + foo: "^1.0.0", + bar: "^2.0.0", + baz: "1.2.3", + }, + }); + } + } + ); + }); + + test("installWrangler", async ({ expect }) => { + const mockPkgJson = { + devDependencies: { + wrangler: "^4.0.0", + }, + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson)); + vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson); + + await installWrangler("npm", false); + + expect(vi.mocked(runCommand)).toHaveBeenCalledWith( + ["npm", "install", "--save-dev", "wrangler@latest"], + expect.anything() + ); + }); + + test("installWrangler updates existing workers-types", async ({ expect }) => { + const mockPkgJson = { + devDependencies: { + "@cloudflare/workers-types": "^4.20241011.0", + wrangler: "^3.60.3", + }, + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson)); + vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson); + + await installWrangler("npm", false); + + expect(vi.mocked(runCommand)).toHaveBeenCalledWith( + [ + "npm", + "install", + "--save-dev", + "wrangler@latest", + "@cloudflare/workers-types@latest", + ], + expect.anything() + ); + }); +}); diff --git a/packages/cli/args.ts b/packages/cli/args.ts new file mode 100644 index 0000000..2e1cf3a --- /dev/null +++ b/packages/cli/args.ts @@ -0,0 +1,21 @@ +import { inputPrompt } from "./interactive"; +import type { Arg, PromptConfig } from "./interactive"; + +export const processArgument = async ( + args: Record, + name: string, + promptConfig: PromptConfig +) => { + const value = args[name]; + const result = await inputPrompt({ + ...promptConfig, + // Accept the default value if the arg is already set + acceptDefault: promptConfig.acceptDefault ?? value !== undefined, + defaultValue: value ?? promptConfig.defaultValue, + }); + + // Update value in args before returning the result + args[name] = result as Arg; + + return result; +}; diff --git a/packages/cli/check-macos-version.ts b/packages/cli/check-macos-version.ts new file mode 100644 index 0000000..b9389bb --- /dev/null +++ b/packages/cli/check-macos-version.ts @@ -0,0 +1,109 @@ +import os from "node:os"; +import { UserError } from "@cloudflare/workers-utils"; +import ci from "ci-info"; + +/** + * Minimum macOS version required for workerd compatibility. + */ +const MINIMUM_MACOS_VERSION = "13.5.0"; + +/** + * Checks the current macOS version for workerd compatibility. + * + * This function is a no-op on non-Darwin platforms and in CI environments. + * + * @param options - Configuration object + * @param options.shouldThrow - If true, throws an error on unsupported versions. If false, logs a warning. + */ +export function checkMacOSVersion(options: { shouldThrow: boolean }): void { + if (process.platform !== "darwin") { + return; + } + + if (ci.isCI) { + return; + } + + const release = os.release(); + const macOSVersion = darwinVersionToMacOSVersion(release); + + if (macOSVersion && isVersionLessThan(macOSVersion, MINIMUM_MACOS_VERSION)) { + if (options.shouldThrow) { + throw new UserError( + `Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (${macOSVersion}). ` + + `The minimum requirement is macOS ${MINIMUM_MACOS_VERSION}+. See https://github.com/cloudflare/workerd?tab=readme-ov-file#running-workerd ` + + `If you cannot upgrade your version of macOS, you could try running in a DevContainer setup with a supported version of Linux (glibc 2.35+ required).`, + { telemetryMessage: false } + ); + } else { + /* eslint-disable-next-line no-console -- this package has no logger to use + (TODO: disable the `no-console` rule on packages that do not use a logger) */ + console.warn( + `⚠️ Warning: Unsupported macOS version detected (${macOSVersion}). ` + + `The Cloudflare Workers runtime may not work correctly on macOS versions below ${MINIMUM_MACOS_VERSION}. ` + + `Consider upgrading to macOS ${MINIMUM_MACOS_VERSION}+ or using a DevContainer setup with a supported version of Linux (glibc 2.35+ required).` + ); + } + } +} + +/** + * Converts Darwin kernel version to macOS version. + * Darwin 21.x.x = macOS 12.x (Monterey) + * Darwin 22.x.x = macOS 13.x (Ventura) + * Darwin 23.x.x = macOS 14.x (Sonoma) + * etc. + */ +function darwinVersionToMacOSVersion(darwinVersion: string): string | null { + const match = darwinVersion.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) { + return null; + } + + const major = parseInt(match[1], 10); + + if (major >= 20) { + const macOSMajor = major - 9; + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3], 10); + return `${macOSMajor}.${minor}.${patch}`; + } + + return null; +} + +/** + * Simple semver comparison for major.minor.patch versions. + * Validates that both versions follow the M.m.p format before comparison. + */ +function isVersionLessThan(version1: string, version2: string): boolean { + const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/; + + const match1 = version1.match(versionRegex); + const match2 = version2.match(versionRegex); + + if (!match1 || !match2) { + throw new Error( + `Invalid version format. Expected M.m.p format, got: ${version1}, ${version2}` + ); + } + + const [major1, minor1, patch1] = [ + parseInt(match1[1], 10), + parseInt(match1[2], 10), + parseInt(match1[3], 10), + ]; + const [major2, minor2, patch2] = [ + parseInt(match2[1], 10), + parseInt(match2[2], 10), + parseInt(match2[3], 10), + ]; + + if (major1 !== major2) { + return major1 < major2; + } + if (minor1 !== minor2) { + return minor1 < minor2; + } + return patch1 < patch2; +} diff --git a/packages/cli/colors.ts b/packages/cli/colors.ts new file mode 100644 index 0000000..bd172df --- /dev/null +++ b/packages/cli/colors.ts @@ -0,0 +1,34 @@ +import chalk from "chalk"; + +const { white, gray, dim, hidden, bold, cyanBright, bgCyan } = chalk; + +const brandColor = chalk.hex("#BD5B08"); + +const black = chalk.hex("#111"); +const blue = chalk.hex("#0E838F"); +const bgBlue = black.bgHex("#0E838F"); +const red = chalk.hex("#AB2526"); +const bgRed = black.bgHex("#AB2526"); +const green = chalk.hex("#218529"); +const bgGreen = black.bgHex("#218529"); +const yellow = chalk.hex("#7F7322"); +const bgYellow = black.bgHex("#7F7322"); + +export { + blue, + bgBlue, + green, + bgGreen, + red, + bgRed, + yellow, + bgYellow, + cyanBright, + bgCyan, + brandColor, + dim, + white, + gray, + hidden, + bold, +}; diff --git a/packages/cli/command.ts b/packages/cli/command.ts new file mode 100644 index 0000000..e882dac --- /dev/null +++ b/packages/cli/command.ts @@ -0,0 +1,189 @@ +import { spawn } from "cross-spawn"; +import { CancelError } from "./error"; +import { isInteractive, spinner } from "./interactive"; +import { stripAnsi } from "."; + +/** + * Command is a string array, like ['git', 'commit', '-m', '"Initial commit"'] + */ +type Command = string[]; + +export type RunOptions = { + startText?: string; + doneText?: string | ((output: string) => string); + silent?: boolean; + captureOutput?: boolean; + useSpinner?: boolean; + env?: NodeJS.ProcessEnv; + cwd?: string; + /** If defined this function is called to all you to transform the output from the command into a new string. */ + transformOutput?: (output: string) => string; +}; + +type PrintOptions = { + promise: Promise | (() => Promise); + useSpinner?: boolean; + startText: string; + doneText?: string | ((output: T) => string); +}; + +/** + * An awaitable wrapper around `spawn` that optionally displays progress to user and processes/captures the command's output + * + * @param command - The command to run as an array of strings + * @param opts.silent - Should the command's stdout and stderr be dispalyed to the user + * @param opts.captureOutput - Should the output of the command the returned as a string. + * @param opts.env - An object of environment variables to be injected when running the command + * @param opts.cwd - The directory in which the command should be run + * @param opts.useSpinner - Should a spinner be shown when running the command + * @param opts.startText - Spinner start text + * @param opts.endText - Spinner end text + * @param opts.transformOutput - A transformer to be run on command output before returning + * + * @returns Output collected from the stdout of the command, if `captureOutput` was set to true. Otherwise `null`. + */ +export const runCommand = async ( + command: Command, + opts: RunOptions = {} +): Promise => { + return printAsyncStatus({ + useSpinner: opts.useSpinner ?? opts.silent, + startText: opts.startText || quoteShellArgs(command), + doneText: opts.doneText, + promise() { + const [executable, ...args] = command; + const abortController = new AbortController(); + const cmd = spawn(executable, [...args], { + // TODO: ideally inherit stderr, but npm install uses this for warnings + // stdio: [ioMode, ioMode, "inherit"], + stdio: opts.silent ? "pipe" : "inherit", + env: { + ...process.env, + ...opts.env, + }, + cwd: opts.cwd, + signal: abortController.signal, + }); + + let output = ``; + + if (opts.captureOutput ?? opts.silent) { + cmd.stdout?.on("data", (data) => { + output += data; + }); + cmd.stderr?.on("data", (data) => { + output += data; + }); + } + + let cleanup: (() => void) | null = null; + + return new Promise((resolvePromise, reject) => { + const cancel = (signal?: NodeJS.Signals) => { + reject(new CancelError(`Command cancelled`, signal)); + abortController.abort(signal ? `${signal} received` : null); + }; + + process.on("SIGTERM", cancel).on("SIGINT", cancel); + + // To cleanup the signal listeners when the promise settles + cleanup = () => { + process.off("SIGTERM", cancel).off("SIGINT", cancel); + }; + + cmd.on("close", (code) => { + try { + if (code !== 0) { + throw new Error(output, { cause: code }); + } + + // Process any captured output + const transformOutput = + opts.transformOutput ?? ((result: string) => result); + const processedOutput = transformOutput(stripAnsi(output)); + + // Send the captured (and processed) output back to the caller + resolvePromise(processedOutput); + } catch (e) { + // Something went wrong. + // Perhaps the command or the transform failed. + reject(new Error(output, { cause: e })); + } + }); + + cmd.on("error", (error) => { + reject(error); + }); + }).finally(() => { + cleanup?.(); + }); + }, + }); +}; + +const printAsyncStatus = async ({ + promise, + ...opts +}: PrintOptions): Promise => { + let s: ReturnType | undefined; + + if (opts.useSpinner && isInteractive()) { + s = spinner(); + } + + s?.start(opts?.startText); + + if (typeof promise === "function") { + promise = promise(); + } + + try { + const output = await promise; + + const doneText = + typeof opts.doneText === "function" + ? opts.doneText(output) + : opts.doneText; + s?.stop(doneText); + } catch (err) { + s?.stop((err as Error).message); + } finally { + s?.stop(); + } + + return promise; +}; + +/** + * Formats an array of command line arguments to be displayed to the user + * in a platform safe way. Args used in conjunction with `runCommand` are safe + * since we use `cross-spawn` to handle multi-platform support. + * + * However, when we output commands to the user, we have to make sure that they + * are compatible with the platform they are using. + * + * @param args - The arguments to format to the user + */ +export function quoteShellArgs(args: string[]): string { + if (process.platform === "win32") { + // Simple Windows command prompt quoting if there are special characters. + const specialCharsMatcher = /[&<>[\]|{}^=;!'+,`~\s]/; + return args + .map((arg) => + arg.match(specialCharsMatcher) ? `"${arg.replaceAll(`"`, `""`)}"` : arg + ) + .join(" "); + } else { + return args + .map((s) => { + if (/["\s]/.test(s) && !/'/.test(s)) { + return "'" + s.replace(/(['\\])/g, "\\$1") + "'"; + } + if (/["'\s]/.test(s)) { + return '"' + s.replace(/(["\\$`!])/g, "\\$1") + '"'; + } + return s; + }) + .join(" "); + } +} diff --git a/packages/cli/cursor.ts b/packages/cli/cursor.ts new file mode 100644 index 0000000..dd3f29b --- /dev/null +++ b/packages/cli/cursor.ts @@ -0,0 +1,13 @@ +import process from "node:process"; + +export function showCursor(show: boolean, stream = process.stderr) { + if (!stream.isTTY) { + return; + } + + if (show) { + stream.write("\x1b[?25h"); + } else { + stream.write("\x1b[?25l"); + } +} diff --git a/packages/cli/error.ts b/packages/cli/error.ts new file mode 100644 index 0000000..d405afc --- /dev/null +++ b/packages/cli/error.ts @@ -0,0 +1,11 @@ +/** + * Error thrown when an operation is cancelled + */ +export class CancelError extends Error { + constructor( + message?: string, + readonly signal?: NodeJS.Signals + ) { + super(message); + } +} diff --git a/packages/cli/gitignore.ts b/packages/cli/gitignore.ts new file mode 100644 index 0000000..65c67da --- /dev/null +++ b/packages/cli/gitignore.ts @@ -0,0 +1,151 @@ +import { appendFileSync, existsSync, statSync, writeFileSync } from "node:fs"; +import { basename } from "node:path"; +import { readFileSync } from "@cloudflare/workers-utils"; +import { brandColor, dim } from "./colors"; +import { spinner } from "./interactive"; + +/** + * Appends to a file that follows a .gitignore-like structure any missing + * Wrangler-related entries (`.wrangler`, `.dev.vars*`, `.env*`, and their + * negated example patterns). + * + * Creates the file if it does not already exist. + * + * @param filePath Absolute or relative path to the ignore file to update. + */ +export function maybeAppendWranglerToGitIgnoreLikeFile(filePath: string): void { + const filePreExisted = existsSync(filePath); + + if (!filePreExisted) { + writeFileSync(filePath, ""); + } + + const existingGitIgnoreContent = readFileSync(filePath); + const wranglerGitIgnoreFilesToAdd: string[] = []; + + const hasDotWrangler = existingGitIgnoreContent.match( + /^\/?\.wrangler(\/|\s|$)/m + ); + if (!hasDotWrangler) { + wranglerGitIgnoreFilesToAdd.push(".wrangler"); + } + + const hasDotDevDotVars = existingGitIgnoreContent.match( + /^\/?\.dev\.vars\*(\s|$)/m + ); + if (!hasDotDevDotVars) { + wranglerGitIgnoreFilesToAdd.push(".dev.vars*"); + } + + const hasDotDevVarsExample = existingGitIgnoreContent.match( + /^!\/?\.dev\.vars\.example(\s|$)/m + ); + if (!hasDotDevVarsExample) { + wranglerGitIgnoreFilesToAdd.push("!.dev.vars.example"); + } + + /** + * We check for the following type of occurrences: + * + * ``` + * .env + * .env* + * .env. + * .env*. + * ``` + * + * Any of these may alone on a line or be followed by a space and a trailing comment: + * + * ``` + * .env. # some trailing comment + * ``` + */ + const hasDotEnv = existingGitIgnoreContent.match( + /^\/?\.env\*?(\..*?)?(\s|$)/m + ); + if (!hasDotEnv) { + wranglerGitIgnoreFilesToAdd.push(".env*"); + } + + const hasDotEnvExample = existingGitIgnoreContent.match( + /^!\/?\.env\.example(\s|$)/m + ); + if (!hasDotEnvExample) { + wranglerGitIgnoreFilesToAdd.push("!.env.example"); + } + + if (wranglerGitIgnoreFilesToAdd.length === 0) { + return; + } + + const s = spinner(); + s.start(`Adding Wrangler files to the ${basename(filePath)} file`); + + const linesToAppend = [ + ...(filePreExisted + ? ["", ...(!existingGitIgnoreContent.match(/\n\s*$/) ? [""] : [])] + : []), + ]; + + if (!hasDotWrangler && wranglerGitIgnoreFilesToAdd.length > 1) { + linesToAppend.push("# wrangler files"); + } + + wranglerGitIgnoreFilesToAdd.forEach((line) => linesToAppend.push(line)); + + linesToAppend.push(""); + + appendFileSync(filePath, linesToAppend.join("\n")); + + const fileName = basename(filePath); + + s.stop( + `${brandColor(filePreExisted ? "updated" : "created")} ${dim( + `${fileName} file` + )}` + ); +} + +/** + * Appends any missing Wrangler-related entries to the project's `.gitignore` file. + * + * Bails out only when *neither* a `.gitignore` file nor a `.git` directory exists, + * which indicates the project is likely not targeting/using git. If either one is + * present the entries are appended (creating `.gitignore` if needed). + * + * @param projectPath Root directory of the project. + */ +export function maybeAppendWranglerToGitIgnore(projectPath: string): void { + const gitIgnorePath = `${projectPath}/.gitignore`; + const gitIgnorePreExisted = existsSync(gitIgnorePath); + const gitDirExists = directoryExists(`${projectPath}/.git`); + + if (!gitIgnorePreExisted && !gitDirExists) { + // if there is no .gitignore file and neither a .git directory + // then bail as the project is likely not targeting/using git + return; + } + + maybeAppendWranglerToGitIgnoreLikeFile(gitIgnorePath); +} + +/** + * Checks whether a directory exists at the given path. + * + * Returns `false` when the path does not exist (`ENOENT`). Re-throws any + * other filesystem error. + * + * @param path Path to check + * @returns `true` if a directory exists at `path`, `false` otherwise + */ +function directoryExists(path: string): boolean { + try { + const stat = statSync(path); + return stat.isDirectory(); + } catch (error) { + if ((error as { code: string }).code === "ENOENT") { + return false; + } + throw new Error(error as string); + } +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100644 index 0000000..4c8b63d --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,280 @@ +import { exit } from "node:process"; +import { + bgBlue, + bgGreen, + bgRed, + bgYellow, + brandColor, + dim, + gray, + hidden, + white, +} from "./colors"; +import { stderr, stdout } from "./streams"; + +export const shapes = { + diamond: "◇", + dash: "─", + radioInactive: "○", + radioActive: "●", + + backActive: "◀", + backInactive: "◁", + + bar: "│", + leftT: "├", + rigthT: "┤", + + arrows: { + left: "‹", + right: "›", + }, + + corners: { + tl: "╭", + bl: "╰", + tr: "╮", + br: "╯", + }, +}; + +export const status = { + error: bgRed(` ERROR `), + warning: bgYellow(` WARNING `), + info: bgBlue(` INFO `), + success: bgGreen(` SUCCESS `), + cancel: white.bgRed(` X `), +}; + +// Returns a string containing n non-trimmable spaces +// This is useful for places where clack trims lines of output +// but we need leading spaces +export const space = (n = 1) => { + return hidden("\u200A".repeat(n)); +}; + +const LOGGER_LEVELS = { + none: -1, + error: 0, + warn: 1, + info: 2, + log: 3, + debug: 4, +} as const; + +export type LoggerLevel = keyof typeof LOGGER_LEVELS; + +// Global log level that can be set by consuming packages +let currentLogLevel: LoggerLevel = "log"; + +export function setLogLevel(level: LoggerLevel) { + currentLogLevel = level; +} + +export function getLogLevel(): LoggerLevel { + return currentLogLevel; +} + +// Primitive for printing to stdout. Use this instead of +// console.log or printing to stdout directly +export const logRaw = (msg: string) => { + // treat all log calls as 'log' level logs + const currentLevel = getLogLevel(); + + // Only output if current log level allows 'log' level messages + if (LOGGER_LEVELS[currentLevel] >= LOGGER_LEVELS.log) { + stdout.write(`${msg}\n`); + } +}; + +// A simple stylized log for use within a prompt +export const log = (msg: string) => { + const lines = msg + .split("\n") + .map((ln) => `${gray(shapes.bar)}${ln.length > 0 ? " " + white(ln) : ""}`); + + logRaw(lines.join("\n")); +}; + +export const newline = () => { + log(""); +}; + +type FormatOptions = { + linePrefix?: string; + firstLinePrefix?: string; + newlineBefore?: boolean; + newlineAfter?: boolean; + formatLine?: (line: string) => string; + multiline?: boolean; +}; +export const format = ( + msg: string, + { + linePrefix = gray(shapes.bar), + firstLinePrefix = linePrefix, + newlineBefore = false, + newlineAfter = false, + formatLine = (line: string) => white(line), + multiline = true, + }: FormatOptions = {} +) => { + const lines = multiline ? msg.split("\n") : [msg]; + const formattedLines = lines.map( + (line, i) => + (i === 0 ? firstLinePrefix : linePrefix) + space() + formatLine(line) + ); + + if (newlineBefore) { + formattedLines.unshift(linePrefix); + } + if (newlineAfter) { + formattedLines.push(linePrefix); + } + + return formattedLines.join("\n"); +}; + +// Log a simple status update with a style similar to the clack spinner +export const updateStatus = (msg: string, printNewLine = true) => { + logRaw( + format(msg, { + firstLinePrefix: gray(shapes.leftT), + linePrefix: gray(shapes.bar), + newlineAfter: printNewLine, + }) + ); +}; + +export const startSection = ( + heading: string, + subheading?: string, + printNewLine = true +) => { + logRaw( + `${gray(shapes.corners.tl)} ${brandColor(heading)} ${ + subheading ? dim(subheading) : "" + }` + ); + if (printNewLine) { + newline(); + } +}; + +export const endSection = (heading: string, subheading?: string) => { + logRaw( + `${gray(shapes.corners.bl)} ${brandColor(heading)} ${ + subheading ? dim(subheading) : "" + }\n` + ); +}; + +export const cancel = ( + msg: string, + { + // current default is backcompat and makes sense going forward too + shape = shapes.corners.bl, + // current default for backcompat -- TODO: change default to true once all callees have been updated + multiline = false, + } = {} +) => { + logRaw( + format(msg, { + firstLinePrefix: `${gray(shape)} ${status.cancel}`, + linePrefix: gray(shapes.bar), + newlineBefore: true, + formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim" + multiline, + }) + ); +}; + +export const warn = ( + msg: string, + { + // current default for backcompat -- TODO: change default to shapes.bar once all callees have been updated + shape = shapes.corners.bl, + // current default for backcompat -- TODO: change default to true once all callees have been updated + multiline = false, + newlineBefore = true, + } = {} +) => { + logRaw( + format(msg, { + firstLinePrefix: gray(shape) + space() + status.warning, + linePrefix: gray(shapes.bar), + formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim" + multiline, + newlineBefore, + }) + ); +}; + +export const success = ( + msg: string, + { + // current default for backcompat -- TODO: change default to shapes.bar once all callees have been updated + shape = shapes.corners.bl, + // current default for backcompat -- TODO: change default to true once all callees have been updated + multiline = false, + } = {} +) => { + logRaw( + format(msg, { + firstLinePrefix: gray(shape) + space() + status.success, + linePrefix: gray(shapes.bar), + newlineBefore: true, + formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim" + multiline, + }) + ); +}; + +// Strip the ansi color characters out of the line when calculating +// line length, otherwise the padding will be thrown off +// Used from https://github.com/natemoo-re/clack/blob/main/packages/prompts/src/index.ts +export const stripAnsi = (str: string) => { + const pattern = [ + "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", + "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))", + ].join("|"); + const regex = RegExp(pattern, "g"); + + return str.replace(linkRegex, "$2").replace(regex, ""); +}; + +// Regular Expression that matches a hyperlink +// e.g. `\u001B]8;;http://example.com/\u001B\\This is a link\u001B]8;;\u001B\` +export const linkRegex = + // eslint-disable-next-line no-control-regex -- regex intentionally matches ANSI escape sequences for hyperlink parsing + /\u001B\]8;;(?.+)\u001B\\(?